index.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. import BaseFoundation, { DefaultAdapter } from '../../base/foundation';
  2. import { DEFAULT_SIZE, Size, NumberSize, Direction, NewSize, ResizeEventType } from "../types";
  3. import { getStringSize, getNumberSize, has, calculateNewMax, findNextSnap, snap, clamp } from "../utils";
  4. export interface ResizableHandlerAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
  5. registerEvent: () => void;
  6. unregisterEvent: () => void
  7. }
  8. export class ResizableHandlerFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<ResizableHandlerAdapter<P, S>, P, S> {
  9. constructor(adapter: ResizableHandlerAdapter<P, S>) {
  10. super({ ...adapter });
  11. }
  12. init(): void {
  13. this._adapter.registerEvent();
  14. }
  15. onMouseDown = (e: MouseEvent) => {
  16. this.getProp('onResizeStart')(e, this.getProp('direction'), 'mouse');
  17. };
  18. onTouchStart = (e: TouchEvent) => {
  19. const touch = e.targetTouches[0];
  20. this.getProp('onResizeStart')(touch, this.getProp('direction'), 'touch');
  21. }
  22. destroy(): void {
  23. this._adapter.unregisterEvent();
  24. }
  25. }
  26. export interface ResizableAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
  27. getResizable: () => HTMLDivElement | null;
  28. registerEvent: (type: ResizeEventType) => void;
  29. unregisterEvent: (type: ResizeEventType) => void
  30. }
  31. export class ResizableFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<ResizableAdapter<P, S>, P, S> {
  32. constructor(adapter: ResizableAdapter<P, S>) {
  33. super({ ...adapter });
  34. }
  35. init(): void {
  36. if (!this.resizable || !this.window) {
  37. return;
  38. }
  39. const flexBasis = this.window.getComputedStyle(this.resizable).flexBasis;
  40. this.setState({
  41. width: this.propSize.width,
  42. height: this.propSize.height,
  43. flexBasis: flexBasis !== 'auto' ? flexBasis : undefined,
  44. } as any);
  45. this.onResizeStart = this.onResizeStart.bind(this);
  46. this.onMouseMove = this.onMouseMove.bind(this);
  47. this.onMouseUp = this.onMouseUp.bind(this);
  48. }
  49. flexDirection?: 'row' | 'column';
  50. type?: ResizeEventType;
  51. lockAspectRatio = 1;
  52. resizable: HTMLElement | null = null;
  53. parentLeft = 0;
  54. parentTop = 0;
  55. boundaryLeft = 0;
  56. boundaryRight = 0;
  57. boundaryTop = 0;
  58. boundaryBottom = 0;
  59. targetLeft = 0;
  60. targetTop = 0;
  61. get parent(): HTMLElement | null {
  62. if (!this.resizable) {
  63. return null;
  64. }
  65. return this.resizable.parentNode as HTMLElement;
  66. }
  67. get window(): Window | null {
  68. if (!this.resizable) {
  69. return null;
  70. }
  71. if (!this.resizable.ownerDocument) {
  72. return null;
  73. }
  74. return this.resizable.ownerDocument.defaultView as Window;
  75. }
  76. get propSize(): Size {
  77. const porps = this.getProps();
  78. return porps.size || porps.defaultSize || DEFAULT_SIZE;
  79. }
  80. get size(): NumberSize {
  81. let width = 0;
  82. let height = 0;
  83. if (this.resizable && this.window) {
  84. width = this.resizable.offsetWidth ;
  85. height = this.resizable.offsetHeight ;
  86. }
  87. return { width, height };
  88. }
  89. get sizeStyle(): { width: string; height: string } {
  90. const size = this.getProp('size');
  91. const getSize = (property: 'width' | 'height'): string => {
  92. const value = this.getStates()[property];
  93. if (typeof value === 'undefined' || value === 'auto') {
  94. return 'auto';
  95. }
  96. const propSizeValue = this.propSize?.[property];
  97. if (propSizeValue?.toString().endsWith('%')) {
  98. if (value.toString().endsWith('%')) {
  99. return value.toString();
  100. }
  101. const parentSize = this.getParentSize();
  102. const numberValue = Number(value.toString().replace('px', ''));
  103. const percentValue = (numberValue / parentSize[property]) * 100;
  104. return `${percentValue}%`;
  105. }
  106. return getStringSize(value);
  107. };
  108. const isResizing = this.getStates().isResizing;
  109. const width = size && typeof size.width !== 'undefined' && !isResizing
  110. ? getStringSize(size.width)
  111. : getSize('width');
  112. const height = size && typeof size.height !== 'undefined' && !isResizing
  113. ? getStringSize(size.height)
  114. : getSize('height');
  115. return { width, height };
  116. }
  117. getParentSize(): { width: number; height: number } {
  118. const appendPseudo = () => {
  119. if (!this.resizable || !this.window) {
  120. return null;
  121. }
  122. const parent = this.parent;
  123. if (!parent) {
  124. return null;
  125. }
  126. const pseudoEle = this.window.document.createElement('div');
  127. pseudoEle.style.width = '100%';
  128. pseudoEle.style.height = '100%';
  129. pseudoEle.style.position = 'absolute';
  130. pseudoEle.style.transform = 'scale(0, 0)';
  131. pseudoEle.style.left = '0';
  132. pseudoEle.style.flex = '0 0 100%';
  133. parent.appendChild(pseudoEle);
  134. return pseudoEle;
  135. };
  136. const removePseudo = (pseudo: HTMLElement) => {
  137. const parent = this.parent;
  138. if (!parent) {
  139. return;
  140. }
  141. parent.removeChild(pseudo);
  142. };
  143. if (!this.parent) {
  144. if (!this.window) {
  145. return { width: 0, height: 0 };
  146. }
  147. return { width: this.window.innerWidth, height: this.window.innerHeight };
  148. }
  149. const pseudoElement = appendPseudo();
  150. if (!pseudoElement) {
  151. return { width: 0, height: 0 };
  152. }
  153. let flexWrapChanged = false;
  154. const originalFlexWrap = this.parent.style.flexWrap;
  155. if (originalFlexWrap !== 'wrap') {
  156. flexWrapChanged = true;
  157. this.parent.style.flexWrap = 'wrap';
  158. }
  159. pseudoElement.style.position = 'relative';
  160. pseudoElement.style.minWidth = '100%';
  161. pseudoElement.style.minHeight = '100%';
  162. const size = {
  163. width: pseudoElement.offsetWidth,
  164. height: pseudoElement.offsetHeight,
  165. };
  166. if (flexWrapChanged) {
  167. this.parent.style.flexWrap = originalFlexWrap;
  168. }
  169. removePseudo(pseudoElement);
  170. return size;
  171. }
  172. registerEvents() {
  173. this._adapter.registerEvent(this.type);
  174. }
  175. unregisterEvents() {
  176. this._adapter.unregisterEvent(this.type);
  177. }
  178. getCssPropertySize(newSize: number | string, property: 'width' | 'height'): number | string {
  179. const propSizeValue = this.propSize?.[property];
  180. const state = this.getStates();
  181. const isAutoSize =
  182. state[property] === 'auto' &&
  183. state.original[property] === newSize &&
  184. (typeof propSizeValue === 'undefined' || propSizeValue === 'auto');
  185. return isAutoSize ? 'auto' : newSize;
  186. }
  187. calBoundaryMax(maxWidth?: number, maxHeight?: number) {
  188. const { boundsByDirection } = this.getProps();
  189. const { direction } = this.getStates();
  190. const isWidthConstrained = boundsByDirection && has('left', direction);
  191. const isHeightConstrained = boundsByDirection && has('top', direction);
  192. let maxWidthConstraint: number;
  193. let maxHeightConstraint: number;
  194. const props = this.getProps();
  195. if (props.boundElement === 'parent') {
  196. const parentElement = this.parent;
  197. if (parentElement) {
  198. maxWidthConstraint = isWidthConstrained
  199. ? this.boundaryRight - this.parentLeft
  200. : parentElement.offsetWidth + (this.parentLeft - this.boundaryLeft);
  201. maxHeightConstraint = isHeightConstrained
  202. ? this.boundaryBottom - this.parentTop
  203. : parentElement.offsetHeight + (this.parentTop - this.boundaryTop);
  204. }
  205. } else if (props.boundElement === 'window' && this.window) {
  206. maxWidthConstraint = isWidthConstrained
  207. ? this.boundaryRight
  208. : this.window.innerWidth - this.boundaryLeft;
  209. maxHeightConstraint = isHeightConstrained
  210. ? this.boundaryBottom
  211. : this.window.innerHeight - this.boundaryTop;
  212. } else if (props.boundElement) {
  213. const boundary = props.boundElement;
  214. maxWidthConstraint = isWidthConstrained
  215. ? this.boundaryRight - this.targetLeft
  216. : boundary.offsetWidth + (this.targetLeft - this.boundaryLeft);
  217. maxHeightConstraint = isHeightConstrained
  218. ? this.boundaryBottom - this.targetTop
  219. : boundary.offsetHeight + (this.targetTop - this.boundaryTop);
  220. }
  221. if (maxWidthConstraint && Number.isFinite(maxWidthConstraint)) {
  222. maxWidth = maxWidth && maxWidth < maxWidthConstraint ? maxWidth : maxWidthConstraint;
  223. }
  224. if (maxHeightConstraint && Number.isFinite(maxHeightConstraint)) {
  225. maxHeight = maxHeight && maxHeight < maxHeightConstraint ? maxHeight : maxHeightConstraint;
  226. }
  227. return { maxWidth, maxHeight };
  228. }
  229. calDirectionSize(clientX: number, clientY: number) {
  230. const props = this.getProps();
  231. const scale = props.scale || 1;
  232. let aspectRatio = props.ratio;
  233. const [resizeRatioX, resizeRatioY] = Array.isArray(aspectRatio) ? aspectRatio : [aspectRatio, aspectRatio];
  234. const { direction, original } = this.getStates();
  235. const { lockAspectRatio, lockAspectRatioExtraHeight = 0, lockAspectRatioExtraWidth = 0 } = props;
  236. let newWidth = original.width;
  237. let newHeight = original.height;
  238. const calculateNewWidth = (deltaX: number) => original.width + (deltaX * resizeRatioX) / scale;
  239. const calculateNewHeight = (deltaY: number) => original.height + (deltaY * resizeRatioY) / scale;
  240. if (has('top', direction)) {
  241. newHeight = calculateNewHeight(original.y - clientY);
  242. if (lockAspectRatio) {
  243. newWidth = (newHeight - lockAspectRatioExtraHeight) * this.lockAspectRatio + lockAspectRatioExtraWidth;
  244. }
  245. }
  246. if (has('bottom', direction)) {
  247. newHeight = calculateNewHeight(clientY - original.y);
  248. if (lockAspectRatio) {
  249. newWidth = (newHeight - lockAspectRatioExtraHeight) * this.lockAspectRatio + lockAspectRatioExtraWidth;
  250. }
  251. }
  252. if (has('right', direction)) {
  253. newWidth = calculateNewWidth(clientX - original.x);
  254. if (lockAspectRatio) {
  255. newHeight = (newWidth - lockAspectRatioExtraWidth) / this.lockAspectRatio + lockAspectRatioExtraHeight;
  256. }
  257. }
  258. if (has('left', direction)) {
  259. newWidth = calculateNewWidth(original.x - clientX);
  260. if (lockAspectRatio) {
  261. newHeight = (newWidth - lockAspectRatioExtraWidth) / this.lockAspectRatio + lockAspectRatioExtraHeight;
  262. }
  263. }
  264. return { newWidth, newHeight };
  265. }
  266. calAspectRatioSize(
  267. newWidth: number,
  268. newHeight: number,
  269. max: { width?: number; height?: number },
  270. min: { width?: number; height?: number },
  271. ) {
  272. const { lockAspectRatio, lockAspectRatioExtraHeight = 0, lockAspectRatioExtraWidth = 0 } = this.getProps();
  273. const minWidth = typeof min.width === 'undefined' ? 10 : min.width;
  274. const maxWidth = typeof max.width === 'undefined' || max.width < 0 ? newWidth : max.width;
  275. const minHeight = typeof min.height === 'undefined' ? 10 : min.height;
  276. const maxHeight = typeof max.height === 'undefined' || max.height < 0 ? newHeight : max.height;
  277. if (lockAspectRatio) {
  278. const adjustedMinWidth = (minHeight - lockAspectRatioExtraHeight) * this.lockAspectRatio + lockAspectRatioExtraWidth;
  279. const adjustedMaxWidth = (maxHeight - lockAspectRatioExtraHeight) * this.lockAspectRatio + lockAspectRatioExtraWidth;
  280. const adjustedMinHeight = (minWidth - lockAspectRatioExtraWidth) / this.lockAspectRatio + lockAspectRatioExtraHeight;
  281. const adjustedMaxHeight = (maxWidth - lockAspectRatioExtraWidth) / this.lockAspectRatio + lockAspectRatioExtraHeight;
  282. const lockedMinWidth = Math.max(minWidth, adjustedMinWidth);
  283. const lockedMaxWidth = Math.min(maxWidth, adjustedMaxWidth);
  284. const lockedMinHeight = Math.max(minHeight, adjustedMinHeight);
  285. const lockedMaxHeight = Math.min(maxHeight, adjustedMaxHeight);
  286. newWidth = clamp(newWidth, lockedMinWidth, lockedMaxWidth);
  287. newHeight = clamp(newHeight, lockedMinHeight, lockedMaxHeight);
  288. } else {
  289. newWidth = clamp(newWidth, minWidth, maxWidth);
  290. newHeight = clamp(newHeight, minHeight, maxHeight);
  291. }
  292. return { newWidth, newHeight };
  293. }
  294. setBoundary() {
  295. const props = this.getProps();
  296. // Set parent boundary
  297. if (props.boundElement === 'parent') {
  298. const parentElement = this.parent;
  299. if (parentElement) {
  300. const parentRect = parentElement.getBoundingClientRect();
  301. this.parentLeft = parentRect.left;
  302. this.parentTop = parentRect.top;
  303. }
  304. }
  305. // Set target (HTML element) boundary
  306. if (props.boundElement && typeof props.boundElement !== 'string') {
  307. const targetRect = props.boundElement.getBoundingClientRect();
  308. this.targetLeft = targetRect.left;
  309. this.targetTop = targetRect.top;
  310. }
  311. // Set resizable boundary
  312. if (this.resizable) {
  313. const { left, top, right, bottom } = this.resizable.getBoundingClientRect();
  314. this.boundaryLeft = left;
  315. this.boundaryRight = right;
  316. this.boundaryTop = top;
  317. this.boundaryBottom = bottom;
  318. }
  319. }
  320. onResizeStart = (e: MouseEvent, direction: Direction, type: ResizeEventType) => {
  321. this.type = type;
  322. this.resizable = this._adapter.getResizable();
  323. if (!this.resizable || !this.window) {
  324. return;
  325. }
  326. const { clientX, clientY } = e;
  327. const props = this.getProps();
  328. const states = this.getStates();
  329. // Call onResizeStart callback if defined
  330. if (props.onResizeStart) {
  331. const shouldContinue = props.onResizeStart(e, direction);
  332. if (shouldContinue === false) {
  333. return;
  334. }
  335. }
  336. // Update state with new size if defined
  337. const { size } = props;
  338. if (size) {
  339. const { height, width } = size;
  340. const { height: currentHeight, width: currentWidth } = states;
  341. if (height !== undefined && height !== currentHeight) {
  342. this.setState({ height } as any);
  343. }
  344. if (width !== undefined && width !== currentWidth) {
  345. this.setState({ width } as any);
  346. }
  347. }
  348. // Handle aspect ratio locking
  349. this.lockAspectRatio = typeof props.lockAspectRatio === 'number'
  350. ? props.lockAspectRatio
  351. : this.size.width / this.size.height;
  352. // Determine flexBasis if applicable
  353. let flexBasis: string | undefined;
  354. const computedStyle = this.window.getComputedStyle(this.resizable);
  355. if (computedStyle.flexBasis !== 'auto') {
  356. const parent = this.parent;
  357. if (parent) {
  358. const parentStyle = this.window.getComputedStyle(parent);
  359. this.flexDirection = parentStyle.flexDirection.startsWith('row') ? 'row' : 'column';
  360. flexBasis = computedStyle.flexBasis;
  361. }
  362. }
  363. // Set bounding rectangle and register events
  364. this.setBoundary();
  365. this.registerEvents();
  366. // Update state with initial resize values
  367. const state = {
  368. original: {
  369. x: clientX,
  370. y: clientY,
  371. width: this.size.width,
  372. height: this.size.height,
  373. },
  374. isResizing: true,
  375. backgroundStyle: {
  376. ...states.backgroundStyle,
  377. cursor: this.window.getComputedStyle(e.target as HTMLElement).cursor || 'auto',
  378. },
  379. direction,
  380. flexBasis,
  381. };
  382. this.setState(state as any);
  383. }
  384. onMouseMove = (event: MouseEvent) => {
  385. this.changePosition(event);
  386. }
  387. onTouchMove = (event: TouchEvent) => {
  388. event.preventDefault();
  389. const touch = event.targetTouches[0];
  390. this.changePosition(touch);
  391. }
  392. changePosition = (event: Touch | MouseEvent) => {
  393. const states = this.getStates();
  394. const props = this.getProps();
  395. if (!states.isResizing || !this.resizable || !this.window) {
  396. return;
  397. }
  398. const { clientX, clientY } = event;
  399. const { direction, original, width, height } = states;
  400. const parentSize = this.getParentSize();
  401. let { maxWidth, maxHeight, minWidth, minHeight } = props;
  402. // Calculate max and min dimensions
  403. const maxBounds = calculateNewMax(
  404. parentSize,
  405. this.window.innerWidth,
  406. this.window.innerHeight,
  407. maxWidth,
  408. maxHeight,
  409. minWidth,
  410. minHeight
  411. );
  412. maxWidth = maxBounds.maxWidth;
  413. maxHeight = maxBounds.maxHeight;
  414. minWidth = maxBounds.minWidth;
  415. minHeight = maxBounds.minHeight;
  416. // Calculate new size based on direction
  417. let { newWidth, newHeight }: NewSize = this.calDirectionSize(clientX, clientY);
  418. // Apply boundary constraints
  419. const boundaryMax = this.calBoundaryMax(maxWidth, maxHeight);
  420. newWidth = getNumberSize(newWidth, parentSize.width, this.window.innerWidth, this.window.innerHeight);
  421. newHeight = getNumberSize(newHeight, parentSize.height, this.window.innerWidth, this.window.innerHeight);
  422. // Apply snapping
  423. if (props.snap) {
  424. if (props.snap.x) {
  425. newWidth = findNextSnap(newWidth, props.snap.x, props.snapGap);
  426. }
  427. if (props.snap.y) {
  428. newHeight = findNextSnap(newHeight, props.snap.y, props.snapGap);
  429. }
  430. }
  431. // Adjust size based on aspect ratio
  432. const sizeFromAspectRatio = this.calAspectRatioSize(
  433. newWidth,
  434. newHeight,
  435. { width: boundaryMax.maxWidth, height: boundaryMax.maxHeight },
  436. { width: minWidth, height: minHeight }
  437. );
  438. newWidth = sizeFromAspectRatio.newWidth;
  439. newHeight = sizeFromAspectRatio.newHeight;
  440. // Apply grid snapping if defined
  441. if (props.grid) {
  442. const [gridW, gridH] = Array.isArray(props.grid) ? props.grid : [props.grid, props.grid];
  443. const gap = props.snapGap || 0;
  444. const newGridWidth = snap(newWidth, gridW);
  445. const newGridHeight = snap(newHeight, gridH);
  446. newWidth = gap === 0 || Math.abs(newGridWidth - newWidth) <= gap ? newGridWidth : newWidth;
  447. newHeight = gap === 0 || Math.abs(newGridHeight - newHeight) <= gap ? newGridHeight : newHeight;
  448. }
  449. // Convert width and height to CSS units if needed
  450. const convertToCssUnit = (size: number, originalSize: number, unit: string): string | number => {
  451. if (unit.endsWith('%')) {
  452. return `${(size / originalSize) * 100}%`;
  453. } else if (unit.endsWith('vw')) {
  454. return `${(size / this.window.innerWidth) * 100}vw`;
  455. } else if (unit.endsWith('vh')) {
  456. return `${(size / this.window.innerHeight) * 100}vh`;
  457. }
  458. return size;
  459. };
  460. if (typeof width === 'string') {
  461. newWidth = convertToCssUnit(newWidth, parentSize.width, width || '');
  462. }
  463. if (typeof height === 'string') {
  464. newHeight = convertToCssUnit(newHeight, parentSize.height, height || '');
  465. }
  466. // Create new state
  467. const newState: { width: string | number; height: string | number; flexBasis?: string | number } = {
  468. width: this.getCssPropertySize(newWidth, 'width'),
  469. height: this.getCssPropertySize(newHeight, 'height')
  470. };
  471. if (this.flexDirection === 'row') {
  472. newState.flexBasis = newState.width;
  473. } else if (this.flexDirection === 'column') {
  474. newState.flexBasis = newState.height;
  475. }
  476. // Check for changes
  477. const widthChanged = states.width !== newState.width;
  478. const heightChanged = states.height !== newState.height;
  479. const flexBaseChanged = states.flexBasis !== newState.flexBasis;
  480. const hasChanges = widthChanged || heightChanged || flexBaseChanged;
  481. if (hasChanges) {
  482. this.setState(newState as any);
  483. // Call onChange callback if defined
  484. if (props.onChange) {
  485. let newSize = {
  486. width: newState.width,
  487. height: newState.height
  488. };
  489. props.onChange(newSize, event, direction);
  490. }
  491. const size = props.size;
  492. if (size) {
  493. this.setState({
  494. width: size.width ?? 'auto',
  495. height: size.height ?? 'auto'
  496. } as any);
  497. }
  498. }
  499. }
  500. onMouseUp = (event: MouseEvent | TouchEvent) => {
  501. const { isResizing, direction, original } = this.getStates();
  502. if (!isResizing || !this.resizable) {
  503. return;
  504. }
  505. const { width: currentWidth, height: currentHeight } = this.size;
  506. const delta = {
  507. width: currentWidth - original.width,
  508. height: currentHeight - original.height,
  509. };
  510. const { onResizeEnd, size } = this.getProps();
  511. // Call onResizeEnd callback if defined
  512. if (onResizeEnd) {
  513. onResizeEnd(this.size, event, direction);
  514. }
  515. // Update state with new size if provided
  516. if (size) {
  517. this.setState({
  518. width: size.width ?? 'auto',
  519. height: size.height ?? 'auto'
  520. } as any);
  521. }
  522. // Unregister events and update state
  523. this.unregisterEvents();
  524. this.setState({
  525. isResizing: false,
  526. backgroundStyle: {
  527. ...this.getStates().backgroundStyle,
  528. cursor: 'auto'
  529. }
  530. } as any);
  531. }
  532. destroy(): void {
  533. this.unregisterEvents();
  534. }
  535. }