From d2152e218d2aacd7a645383ce4e48fdb9f464a25 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Sun, 11 Feb 2024 09:38:12 +0100 Subject: [PATCH 01/16] feat: add iframe support --- package.json | 2 + pnpm-lock.yaml | 287 ++++-------------- .../dimension-marshal/get-initial-publish.ts | 2 +- src/state/get-frame.ts | 1 + .../move-cross-axis/index.ts | 9 +- .../move-to-next-place/index.ts | 9 +- src/types.ts | 1 + src/view/event-bindings/bind-events.ts | 27 +- src/view/get-elements/find-drag-handle.ts | 9 +- src/view/get-elements/find-draggable.ts | 5 +- src/view/iframe/apply-offset.ts | 20 ++ src/view/iframe/get-iframe-offset.ts | 25 ++ src/view/iframe/get-offsetted-box.ts | 17 ++ src/view/iframe/offset-types.ts | 6 + src/view/iframe/query-selector-all-iframe.ts | 21 ++ .../use-draggable-publisher/get-dimension.ts | 19 +- .../use-droppable-publisher/get-dimension.ts | 3 +- .../sensors/use-mouse-sensor.ts | 21 +- .../sensors/use-touch-sensor.ts | 20 +- .../sensors/util/offset-point.ts | 25 ++ .../use-style-marshal/use-style-marshal.ts | 119 +++++--- src/view/window/get-viewport.ts | 16 +- stories/examples/61-iframe.stories.tsx | 19 ++ stories/src/board/column.tsx | 2 +- stories/src/board/iframe-board.tsx | 145 +++++++++ stories/src/primatives/quote-item.tsx | 2 +- 26 files changed, 526 insertions(+), 306 deletions(-) create mode 100644 src/view/iframe/apply-offset.ts create mode 100644 src/view/iframe/get-iframe-offset.ts create mode 100644 src/view/iframe/get-offsetted-box.ts create mode 100644 src/view/iframe/offset-types.ts create mode 100644 src/view/iframe/query-selector-all-iframe.ts create mode 100644 src/view/use-sensor-marshal/sensors/util/offset-point.ts create mode 100644 stories/examples/61-iframe.stories.tsx create mode 100644 stories/src/board/iframe-board.tsx diff --git a/package.json b/package.json index 5decfe59d..a70df75f8 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@emotion/eslint-plugin": "11.11.0", "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", + "@measured/auto-frame-component": "0.1.0-canary.4686711", "@jest/environment": "29.7.0", "@release-it/conventional-changelog": "8.0.1", "@rollup/plugin-babel": "6.0.4", @@ -131,6 +132,7 @@ "@types/jsdom": "21.1.6", "@types/markdown-it": "13.0.7", "@types/node": "20.10.3", + "@types/object-hash": "^3.0.6", "@types/raf-schd": "4.0.3", "@types/react": "18.2.42", "@types/react-dom": "18.2.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ce2d41a..a40c4a5e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ devDependencies: '@jest/environment': specifier: 29.7.0 version: 29.7.0 + '@measured/auto-frame-component': + specifier: 0.1.0-canary.4686711 + version: 0.1.0-canary.4686711(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) '@release-it/conventional-changelog': specifier: 8.0.1 version: 8.0.1(release-it@16.3.0) @@ -172,6 +175,9 @@ devDependencies: '@types/node': specifier: 20.10.3 version: 20.10.3 + '@types/object-hash': + specifier: ^3.0.6 + version: 3.0.6 '@types/raf-schd': specifier: 4.0.3 version: 4.0.3 @@ -216,7 +222,7 @@ devDependencies: version: 5.0.0 commitizen: specifier: 4.3.0 - version: 4.3.0 + version: 4.3.0(typescript@4.9.5) cross-env: specifier: 7.0.3 version: 7.0.3 @@ -285,7 +291,7 @@ devDependencies: version: 8.0.3 jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + version: 29.7.0(@types/node@20.10.3) jest-axe: specifier: 8.0.0 version: 8.0.0 @@ -2339,16 +2345,6 @@ packages: conventional-changelog-conventionalcommits: 7.0.2 dev: true - /@commitlint/config-validator@17.8.1: - resolution: {integrity: sha512-UUgUC+sNiiMwkyiuIFR7JG2cfd9t/7MV8VB4TZ+q02ZFkHoduUS4tJGsCBWvBOGD9Btev6IecPMvlWUfJorkEA==} - engines: {node: '>=v14'} - requiresBuild: true - dependencies: - '@commitlint/types': 17.8.1 - ajv: 8.12.0 - dev: true - optional: true - /@commitlint/config-validator@18.4.3: resolution: {integrity: sha512-FPZZmTJBARPCyef9ohRC9EANiQEKSWIdatx5OlgeHKu878dWwpyeFauVkhzuBRJFcCA4Uvz/FDtlDKs008IHcA==} engines: {node: '>=v18'} @@ -2368,7 +2364,7 @@ packages: '@commitlint/load': 18.4.3(typescript@4.9.5) '@commitlint/types': 18.4.3 chalk: 4.1.2 - commitizen: 4.3.0 + commitizen: 4.3.0(typescript@4.9.5) inquirer: 8.2.5 lodash.isplainobject: 4.0.6 word-wrap: 1.2.5 @@ -2388,13 +2384,6 @@ packages: lodash.upperfirst: 4.3.1 dev: true - /@commitlint/execute-rule@17.8.1: - resolution: {integrity: sha512-JHVupQeSdNI6xzA9SqMF+p/JjrHTcrJdI02PwesQIDCIGUrv04hicJgCcws5nzaoZbROapPs0s6zeVHoxpMwFQ==} - engines: {node: '>=v14'} - requiresBuild: true - dev: true - optional: true - /@commitlint/execute-rule@18.4.3: resolution: {integrity: sha512-t7FM4c+BdX9WWZCPrrbV5+0SWLgT3kCq7e7/GhHCreYifg3V8qyvO127HF796vyFql75n4TFF+5v1asOOWkV1Q==} engines: {node: '>=v18'} @@ -2426,31 +2415,6 @@ packages: '@commitlint/types': 18.4.3 dev: true - /@commitlint/load@17.8.1: - resolution: {integrity: sha512-iF4CL7KDFstP1kpVUkT8K2Wl17h2yx9VaR1ztTc8vzByWWcbO/WaKwxsnCOqow9tVAlzPfo1ywk9m2oJ9ucMqA==} - engines: {node: '>=v14'} - requiresBuild: true - dependencies: - '@commitlint/config-validator': 17.8.1 - '@commitlint/execute-rule': 17.8.1 - '@commitlint/resolve-extends': 17.8.1 - '@commitlint/types': 17.8.1 - '@types/node': 20.5.1 - chalk: 4.1.2 - cosmiconfig: 8.3.6(typescript@4.9.5) - cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6)(ts-node@10.9.1)(typescript@4.9.5) - lodash.isplainobject: 4.0.6 - lodash.merge: 4.6.2 - lodash.uniq: 4.5.0 - resolve-from: 5.0.0 - ts-node: 10.9.1(@types/node@20.10.3)(typescript@4.9.5) - typescript: 4.9.5 - transitivePeerDependencies: - - '@swc/core' - - '@swc/wasm' - dev: true - optional: true - /@commitlint/load@18.4.3(typescript@4.9.5): resolution: {integrity: sha512-v6j2WhvRQJrcJaj5D+EyES2WKTxPpxENmNpNG3Ww8MZGik3jWRXtph0QTzia5ZJyPh2ib5aC/6BIDymkUUM58Q==} engines: {node: '>=v18'} @@ -2496,20 +2460,6 @@ packages: minimist: 1.2.8 dev: true - /@commitlint/resolve-extends@17.8.1: - resolution: {integrity: sha512-W/ryRoQ0TSVXqJrx5SGkaYuAaE/BUontL1j1HsKckvM6e5ZaG0M9126zcwL6peKSuIetJi7E87PRQF8O86EW0Q==} - engines: {node: '>=v14'} - requiresBuild: true - dependencies: - '@commitlint/config-validator': 17.8.1 - '@commitlint/types': 17.8.1 - import-fresh: 3.3.0 - lodash.mergewith: 4.6.2 - resolve-from: 5.0.0 - resolve-global: 1.0.0 - dev: true - optional: true - /@commitlint/resolve-extends@18.4.3: resolution: {integrity: sha512-30sk04LZWf8+SDgJrbJCjM90gTg2LxsD9cykCFeFu+JFHvBFq5ugzp2eO/DJGylAdVaqxej3c7eTSE64hR/lnw==} engines: {node: '>=v18'} @@ -2545,15 +2495,6 @@ packages: find-up: 5.0.0 dev: true - /@commitlint/types@17.8.1: - resolution: {integrity: sha512-PXDQXkAmiMEG162Bqdh9ChML/GJZo6vU+7F03ALKDK8zYc6SuAr47LjG7hGYRqUOz+WK0dU7bQ0xzuqFMdxzeQ==} - engines: {node: '>=v14'} - requiresBuild: true - dependencies: - chalk: 4.1.2 - dev: true - optional: true - /@commitlint/types@18.4.3: resolution: {integrity: sha512-cvzx+vtY/I2hVBZHCLrpoh+sA0hfuzHwDc+BAFPimYLjJkpHnghQM+z8W/KyLGkygJh3BtI3xXXq+dKjnSWEmA==} engines: {node: '>=v18'} @@ -2561,14 +2502,6 @@ packages: chalk: 4.1.2 dev: true - /@cspotcode/source-map-support@0.8.1: - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - requiresBuild: true - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - dev: true - /@csstools/css-parser-algorithms@2.3.2(@csstools/css-tokenizer@2.2.1): resolution: {integrity: sha512-sLYGdAdEY2x7TSw9FtmdaTrh2wFtRJO5VMbBrA8tEqEod7GEggFmxTSK9XqExib3yMuYNcvcTdCZIP6ukdjAIA==} engines: {node: ^14 || ^16 || >=18} @@ -2927,7 +2860,7 @@ packages: slash: 3.0.0 dev: true - /@jest/core@29.7.0(ts-node@10.9.1): + /@jest/core@29.7.0: resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -2948,7 +2881,7 @@ packages: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + jest-config: 29.7.0(@types/node@20.10.3) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -3194,12 +3127,6 @@ packages: engines: {node: '>=6.0.0'} dev: true - /@jridgewell/resolve-uri@3.1.1: - resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} - engines: {node: '>=6.0.0'} - requiresBuild: true - dev: true - /@jridgewell/set-array@1.1.2: resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} engines: {node: '>=6.0.0'} @@ -3227,14 +3154,6 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /@jridgewell/trace-mapping@0.3.9: - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - requiresBuild: true - dependencies: - '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 - dev: true - /@ljharb/through@2.3.9: resolution: {integrity: sha512-yN599ZBuMPPK4tdoToLlvgJB4CLK8fGl7ntfy0Wn7U6ttNvHYurd81bfUiK/6sMkiIwm65R6ck4L6+Y3DfVbNQ==} engines: {node: '>= 0.4'} @@ -3278,6 +3197,19 @@ packages: resolution: {integrity: sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==} dev: true + /@measured/auto-frame-component@0.1.0-canary.4686711(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B3Q38ECW7xc4900PBLvHdEiws+rjx1BdHS+EEIBsw9kadT+4NMjdoTFB6wQKxtGEumI2A6tBa9IoX6UkecBkzQ==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + dependencies: + object-hash: 3.0.0 + react: 18.2.0 + react-frame-component: 5.2.6(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - prop-types + - react-dom + dev: true + /@mrmlnc/readdir-enhanced@2.2.1: resolution: {integrity: sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==} engines: {node: '>=4'} @@ -5248,26 +5180,6 @@ packages: resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} dev: true - /@tsconfig/node10@1.0.9: - resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} - requiresBuild: true - dev: true - - /@tsconfig/node12@1.0.11: - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - requiresBuild: true - dev: true - - /@tsconfig/node14@1.0.3: - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - requiresBuild: true - dev: true - - /@tsconfig/node16@1.0.4: - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - requiresBuild: true - dev: true - /@types/aria-query@5.0.1: resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==} dev: true @@ -5541,12 +5453,6 @@ packages: undici-types: 5.26.5 dev: true - /@types/node@20.5.1: - resolution: {integrity: sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==} - requiresBuild: true - dev: true - optional: true - /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -5555,6 +5461,10 @@ packages: resolution: {integrity: sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==} dev: true + /@types/object-hash@3.0.6: + resolution: {integrity: sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==} + dev: true + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true @@ -6525,11 +6435,6 @@ packages: readable-stream: 3.6.2 dev: true - /arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - requiresBuild: true - dev: true - /argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: @@ -8085,13 +7990,13 @@ packages: engines: {node: '>= 12'} dev: true - /commitizen@4.3.0: + /commitizen@4.3.0(typescript@4.9.5): resolution: {integrity: sha512-H0iNtClNEhT0fotHvGV3E9tDejDeS04sN1veIebsKYGMuGscFaswRoYJKmT3eW85eIJAs0F28bG2+a/9wCOfPw==} engines: {node: '>= 12'} hasBin: true dependencies: cachedir: 2.3.0 - cz-conventional-changelog: 3.3.0 + cz-conventional-changelog: 3.3.0(typescript@4.9.5) dedent: 0.7.0 detect-indent: 6.1.0 find-node-modules: 2.1.3 @@ -8105,8 +8010,7 @@ packages: strip-bom: 4.0.0 strip-json-comments: 3.1.1 transitivePeerDependencies: - - '@swc/core' - - '@swc/wasm' + - typescript dev: true /common-path-prefix@3.0.0: @@ -8434,23 +8338,6 @@ packages: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: true - /cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6)(ts-node@10.9.1)(typescript@4.9.5): - resolution: {integrity: sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw==} - engines: {node: '>=v14.21.3'} - requiresBuild: true - peerDependencies: - '@types/node': '*' - cosmiconfig: '>=7' - ts-node: '>=10' - typescript: '>=4' - dependencies: - '@types/node': 20.5.1 - cosmiconfig: 8.3.6(typescript@4.9.5) - ts-node: 10.9.1(@types/node@20.10.3)(typescript@4.9.5) - typescript: 4.9.5 - dev: true - optional: true - /cosmiconfig-typescript-loader@5.0.0(@types/node@18.19.2)(cosmiconfig@8.3.6)(typescript@4.9.5): resolution: {integrity: sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==} engines: {node: '>=v16'} @@ -8568,7 +8455,7 @@ packages: sha.js: 2.4.11 dev: true - /create-jest@29.7.0(@types/node@20.10.3)(ts-node@10.9.1): + /create-jest@29.7.0(@types/node@20.10.3): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -8577,7 +8464,7 @@ packages: chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + jest-config: 29.7.0(@types/node@20.10.3) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -8587,11 +8474,6 @@ packages: - ts-node dev: true - /create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - requiresBuild: true - dev: true - /cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} @@ -8854,21 +8736,20 @@ packages: yauzl: 2.10.0 dev: true - /cz-conventional-changelog@3.3.0: + /cz-conventional-changelog@3.3.0(typescript@4.9.5): resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} engines: {node: '>= 10'} dependencies: chalk: 2.4.2 - commitizen: 4.3.0 + commitizen: 4.3.0(typescript@4.9.5) conventional-commit-types: 3.0.0 lodash.map: 4.6.0 longest: 2.0.1 word-wrap: 1.2.5 optionalDependencies: - '@commitlint/load': 17.8.1 + '@commitlint/load': 18.4.3(typescript@4.9.5) transitivePeerDependencies: - - '@swc/core' - - '@swc/wasm' + - typescript dev: true /damerau-levenshtein@1.0.8: @@ -9247,12 +9128,6 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true - /diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - requiresBuild: true - dev: true - /diffie-hellman@5.0.3: resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} dependencies: @@ -9893,7 +9768,7 @@ packages: '@typescript-eslint/eslint-plugin': 6.13.2(@typescript-eslint/parser@6.13.2)(eslint@8.55.0)(typescript@4.9.5) '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@4.9.5) eslint: 8.55.0 - jest: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + jest: 29.7.0(@types/node@20.10.3) transitivePeerDependencies: - supports-color - typescript @@ -12804,7 +12679,7 @@ packages: - supports-color dev: true - /jest-cli@29.7.0(@types/node@20.10.3)(ts-node@10.9.1): + /jest-cli@29.7.0(@types/node@20.10.3): resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -12814,14 +12689,14 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 29.7.0(ts-node@10.9.1) + '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + create-jest: 29.7.0(@types/node@20.10.3) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + jest-config: 29.7.0(@types/node@20.10.3) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -12832,7 +12707,7 @@ packages: - ts-node dev: true - /jest-config@29.7.0(@types/node@20.10.3)(ts-node@10.9.1): + /jest-config@29.7.0(@types/node@20.10.3): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -12867,7 +12742,6 @@ packages: pretty-format: 29.7.0 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1(@types/node@20.10.3)(typescript@4.9.5) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -13325,7 +13199,7 @@ packages: dependencies: ansi-escapes: 6.2.0 chalk: 5.3.0 - jest: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + jest: 29.7.0(@types/node@20.10.3) jest-regex-util: 29.4.3 jest-watcher: 29.6.1 slash: 5.1.0 @@ -13389,7 +13263,7 @@ packages: supports-color: 8.1.1 dev: true - /jest@29.7.0(@types/node@20.10.3)(ts-node@10.9.1): + /jest@29.7.0(@types/node@20.10.3): resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -13399,10 +13273,10 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 29.7.0(ts-node@10.9.1) + '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.10.3)(ts-node@10.9.1) + jest-cli: 29.7.0(@types/node@20.10.3) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -14113,11 +13987,6 @@ packages: semver: 7.5.4 dev: true - /make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - requiresBuild: true - dev: true - /makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} dependencies: @@ -14881,6 +14750,11 @@ packages: kind-of: 3.2.2 dev: true + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: true + /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} dev: true @@ -16251,6 +16125,7 @@ packages: react: 18.2.0 scheduler: 0.19.1 dev: true + bundledDependencies: false /react-dom@17.0.2(react@18.2.0): resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==} @@ -16285,6 +16160,18 @@ packages: react-is: 17.0.2 dev: true + /react-frame-component@5.2.6(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CwkEM5VSt6nFwZ1Op8hi3JB5rPseZlmnp5CGiismVTauE6S4Jsc4TNMlT0O7Cts4WgIC3ZBAQ2p1Mm9XgLbj+w==} + peerDependencies: + prop-types: ^15.5.9 + react: '>= 16.3' + react-dom: '>= 16.3' + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + /react-inspector@5.1.1(react@18.2.0): resolution: {integrity: sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==} peerDependencies: @@ -16399,6 +16286,7 @@ packages: object-assign: 4.1.1 prop-types: 15.8.1 dev: true + bundledDependencies: false /react@17.0.2: resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} @@ -18483,38 +18371,6 @@ packages: engines: {node: '>=6.10'} dev: true - /ts-node@10.9.1(@types/node@20.10.3)(typescript@4.9.5): - resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} - hasBin: true - requiresBuild: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.10.3 - acorn: 8.10.0 - acorn-walk: 8.2.0 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 4.9.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - dev: true - /ts-pnp@1.2.0(typescript@4.9.5): resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==} engines: {node: '>=6'} @@ -19081,11 +18937,6 @@ packages: hasBin: true dev: true - /v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - requiresBuild: true - dev: true - /v8-to-istanbul@9.1.0: resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==} engines: {node: '>=10.12.0'} @@ -19761,12 +19612,6 @@ packages: fd-slicer: 1.1.0 dev: true - /yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - requiresBuild: true - dev: true - /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/src/state/dimension-marshal/get-initial-publish.ts b/src/state/dimension-marshal/get-initial-publish.ts index d4b922bb2..048bff8d9 100644 --- a/src/state/dimension-marshal/get-initial-publish.ts +++ b/src/state/dimension-marshal/get-initial-publish.ts @@ -31,7 +31,7 @@ export default ({ }: Args): StartPublishingResult => { const timingKey = 'Initial collection from DOM'; timings.start(timingKey); - const viewport: Viewport = getViewport(); + const viewport: Viewport = getViewport(critical); const windowScroll: Position = viewport.scroll.current; const home: DroppableDescriptor = critical.droppable; diff --git a/src/state/get-frame.ts b/src/state/get-frame.ts index 525ab5db3..d36b6ecdf 100644 --- a/src/state/get-frame.ts +++ b/src/state/get-frame.ts @@ -5,5 +5,6 @@ import type { DroppableDimension, Scrollable } from '../types'; export default (droppable: DroppableDimension): Scrollable => { const frame: Scrollable | null = droppable.frame; invariant(frame, 'Expected Droppable to have a frame'); + return frame; }; diff --git a/src/state/move-in-direction/move-cross-axis/index.ts b/src/state/move-in-direction/move-cross-axis/index.ts index 28abdeb43..2deb7e8b4 100644 --- a/src/state/move-in-direction/move-cross-axis/index.ts +++ b/src/state/move-in-direction/move-cross-axis/index.ts @@ -16,6 +16,7 @@ import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable' import getClientFromPageBorderBoxCenter from '../../get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center'; import getPageBorderBoxCenter from '../../get-center-from-impact/get-page-border-box-center'; import moveToNewDroppable from './move-to-new-droppable'; +import { subtract } from '../../position'; interface Args { isMovingForward: boolean; @@ -100,8 +101,14 @@ export default ({ viewport, }); + // Offset viewport when moving along axis (such as for using keyboard sensor with iframes) + const offsetClientSelection = subtract(clientSelection, { + x: viewport.offset.x, + y: viewport.offset.y, + }); + return { - clientSelection, + clientSelection: offsetClientSelection, impact, scrollJumpRequest: null, }; diff --git a/src/state/move-in-direction/move-to-next-place/index.ts b/src/state/move-in-direction/move-to-next-place/index.ts index dd3f7e6c2..502213ea3 100644 --- a/src/state/move-in-direction/move-to-next-place/index.ts +++ b/src/state/move-in-direction/move-to-next-place/index.ts @@ -103,8 +103,15 @@ export default ({ draggable, viewport, }); + + // Offset viewport when moving along axis (such as for using keyboard sensor with iframes) + const offsetClientSelection = subtract(clientSelection, { + x: viewport.offset.x, + y: viewport.offset.y, + }); + return { - clientSelection, + clientSelection: offsetClientSelection, impact, scrollJumpRequest: null, }; diff --git a/src/types.ts b/src/types.ts index 9d2a22a02..eb48ad45b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -305,6 +305,7 @@ export interface Viewport { // live updates with the latest values frame: Rect; scroll: ScrollDetails; + offset: Rect; } export interface LiftEffect { diff --git a/src/view/event-bindings/bind-events.ts b/src/view/event-bindings/bind-events.ts index 990c2d486..c72cdaab7 100644 --- a/src/view/event-bindings/bind-events.ts +++ b/src/view/event-bindings/bind-events.ts @@ -1,3 +1,5 @@ +import { IframeHTMLAttributes } from 'react'; +import { querySelectorAll } from '../../query-selector-all'; import type { AnyEventBinding, EventBinding, @@ -21,15 +23,26 @@ export default function bindEvents( bindings: AnyEventBinding[], sharedOptions?: EventOptions, ): () => void { - const unbindings: UnbindFn[] = (bindings as EventBinding[]).map( - (binding): UnbindFn => { - const options = getOptions(sharedOptions, binding.options); + const unbindings: UnbindFn[] = (bindings as EventBinding[]).flatMap( + (binding): UnbindFn[] => { + const iframes: HTMLIFrameElement[] = querySelectorAll( + window.document, + 'iframe', + ) as HTMLIFrameElement[]; - el.addEventListener(binding.eventName, binding.fn, options); + const windows = [el, ...iframes.map((iframe) => iframe.contentWindow)]; - return function unbind() { - el.removeEventListener(binding.eventName, binding.fn, options); - }; + return windows.map((win) => { + if (!win) return function unbind() {}; + + const options = getOptions(sharedOptions, binding.options); + + win.addEventListener(binding.eventName, binding.fn, options); + + return function unbind() { + win.removeEventListener(binding.eventName, binding.fn, options); + }; + }); }, ); diff --git a/src/view/get-elements/find-drag-handle.ts b/src/view/get-elements/find-drag-handle.ts index de81d43a3..3aebb10c1 100644 --- a/src/view/get-elements/find-drag-handle.ts +++ b/src/view/get-elements/find-drag-handle.ts @@ -1,8 +1,8 @@ import type { DraggableId, ContextId } from '../../types'; import { dragHandle as dragHandleAttr } from '../data-attributes'; import { warning } from '../../dev-warning'; -import { querySelectorAll } from '../../query-selector-all'; import isHtmlElement from '../is-type-of-element/is-html-element'; +import querySelectorAllIframe from '../iframe/query-selector-all-iframe'; export default function findDragHandle( contextId: ContextId, @@ -10,10 +10,13 @@ export default function findDragHandle( ): HTMLElement | null { // cannot create a selector with the draggable id as it might not be a valid attribute selector const selector = `[${dragHandleAttr.contextId}="${contextId}"]`; - const possible = querySelectorAll(document, selector); + + const possible = querySelectorAllIframe(selector); if (!possible.length) { - warning(`Unable to find any drag handles in the context "${contextId}"`); + warning( + `Unable to find any drag handles in the context "${contextId}" ${selector}`, + ); return null; } diff --git a/src/view/get-elements/find-draggable.ts b/src/view/get-elements/find-draggable.ts index d1df0aad2..ffac459c4 100644 --- a/src/view/get-elements/find-draggable.ts +++ b/src/view/get-elements/find-draggable.ts @@ -1,8 +1,8 @@ import type { DraggableId, ContextId } from '../../types'; import * as attributes from '../data-attributes'; -import { querySelectorAll } from '../../query-selector-all'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; +import querySelectorAllIframe from '../iframe/query-selector-all-iframe'; export default function findDraggable( contextId: ContextId, @@ -10,7 +10,8 @@ export default function findDraggable( ): HTMLElement | null { // cannot create a selector with the draggable id as it might not be a valid attribute selector const selector = `[${attributes.draggable.contextId}="${contextId}"]`; - const possible = querySelectorAll(document, selector); + + const possible = querySelectorAllIframe(selector); const draggable = possible.find((el): boolean => { return el.getAttribute(attributes.draggable.id) === draggableId; diff --git a/src/view/iframe/apply-offset.ts b/src/view/iframe/apply-offset.ts new file mode 100644 index 000000000..d3959a90a --- /dev/null +++ b/src/view/iframe/apply-offset.ts @@ -0,0 +1,20 @@ +import { Rect } from 'css-box-model'; +import { Offset } from './offset-types'; + +export default function applyOffset(rect: Partial, offset: Offset): Rect { + return { + ...rect, + top: (rect.top || 0) + offset.top, + left: (rect.left || 0) + offset.left, + right: (rect.right || 0) + offset.right, + bottom: (rect.bottom || 0) + offset.bottom, + x: (rect.x || 0) + offset.left, + y: (rect.y || 0) + offset.top, + center: { + x: (rect.center?.x || 0) + offset.left, + y: (rect.center?.y || 0) + offset.top, + }, + width: rect.width || 0, + height: rect.height || 0, + }; +} diff --git a/src/view/iframe/get-iframe-offset.ts b/src/view/iframe/get-iframe-offset.ts new file mode 100644 index 000000000..7802e35ef --- /dev/null +++ b/src/view/iframe/get-iframe-offset.ts @@ -0,0 +1,25 @@ +import { Offset } from './offset-types'; + +export default function getIframeOffset(el: HTMLElement) { + const offset: Offset = { + top: 0, + left: 0, + bottom: 0, + right: 0, + }; + + const refWindow = el.ownerDocument.defaultView; + + if (refWindow && refWindow.self !== refWindow.parent) { + const iframe = refWindow.frameElement as HTMLIFrameElement; + + const rect = iframe.getBoundingClientRect(); + + offset.left = rect.left; + offset.top = rect.top; + offset.right = rect.left; + offset.bottom = rect.top; + } + + return offset; +} diff --git a/src/view/iframe/get-offsetted-box.ts b/src/view/iframe/get-offsetted-box.ts new file mode 100644 index 000000000..2b066c436 --- /dev/null +++ b/src/view/iframe/get-offsetted-box.ts @@ -0,0 +1,17 @@ +import { getBox } from 'css-box-model'; +import getIframeOffset from './get-iframe-offset'; +import applyOffset from './apply-offset'; + +export default function getOffsettedBox(el: HTMLElement) { + const box = getBox(el); + + const offset = getIframeOffset(el); + + return { + ...box, + borderBox: applyOffset(box.borderBox, offset), + marginBox: applyOffset(box.marginBox, offset), + paddingBox: applyOffset(box.paddingBox, offset), + contentBox: applyOffset(box.contentBox, offset), + }; +} diff --git a/src/view/iframe/offset-types.ts b/src/view/iframe/offset-types.ts new file mode 100644 index 000000000..8e7f5d514 --- /dev/null +++ b/src/view/iframe/offset-types.ts @@ -0,0 +1,6 @@ +export interface Offset { + top: number; + left: number; + bottom: number; + right: number; +} diff --git a/src/view/iframe/query-selector-all-iframe.ts b/src/view/iframe/query-selector-all-iframe.ts new file mode 100644 index 000000000..a94b048fc --- /dev/null +++ b/src/view/iframe/query-selector-all-iframe.ts @@ -0,0 +1,21 @@ +/** + * querySelectorAllIframe + * + * An proxy of querySelectorAll that also queries all iframes + */ + +import { querySelectorAll } from '../../query-selector-all'; + +export default function querySelectorAllIframe(selector: string) { + const iframes = querySelectorAll(document, 'iframe') as HTMLIFrameElement[]; + + const iframePossible = iframes.reduce( + (acc, iframe) => [ + ...acc, + ...querySelectorAll(iframe.contentWindow!.document, selector), + ], + [], + ); + + return [...querySelectorAll(document, selector), ...iframePossible]; +} diff --git a/src/view/use-draggable-publisher/get-dimension.ts b/src/view/use-draggable-publisher/get-dimension.ts index 29a7e4d55..f6d364e7a 100644 --- a/src/view/use-draggable-publisher/get-dimension.ts +++ b/src/view/use-draggable-publisher/get-dimension.ts @@ -6,6 +6,8 @@ import type { Placeholder, } from '../../types'; import { origin } from '../../state/position'; +import getIframeOffset from '../iframe/get-iframe-offset'; +import applyOffset from '../iframe/apply-offset'; export default function getDimension( descriptor: DraggableDescriptor, @@ -13,9 +15,20 @@ export default function getDimension( windowScroll: Position = origin, ): DraggableDimension { const computedStyles: CSSStyleDeclaration = window.getComputedStyle(el); - const borderBox: ClientRect = el.getBoundingClientRect(); - const client: BoxModel = calculateBox(borderBox, computedStyles); - const page: BoxModel = withScroll(client, windowScroll); + + const offset = getIframeOffset(el); + + const client: BoxModel = calculateBox( + el.getBoundingClientRect(), + computedStyles, + ); + const page: BoxModel = withScroll( + calculateBox( + applyOffset(el.getBoundingClientRect(), offset), + computedStyles, + ), + windowScroll, + ); const placeholder: Placeholder = { client, diff --git a/src/view/use-droppable-publisher/get-dimension.ts b/src/view/use-droppable-publisher/get-dimension.ts index 172bef3b3..c70e28d66 100644 --- a/src/view/use-droppable-publisher/get-dimension.ts +++ b/src/view/use-droppable-publisher/get-dimension.ts @@ -10,12 +10,13 @@ import type { ScrollSize, } from '../../types'; import getScroll from './get-scroll'; +import getOffsettedBox from '../iframe/get-offsetted-box'; const getClient = ( targetRef: HTMLElement, closestScrollable?: Element | null, ): BoxModel => { - const base: BoxModel = getBox(targetRef); + const base: BoxModel = getOffsettedBox(targetRef); // Droppable has no scroll parent if (!closestScrollable) { diff --git a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.ts b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.ts index d35857a06..f99a63b03 100644 --- a/src/view/use-sensor-marshal/sensors/use-mouse-sensor.ts +++ b/src/view/use-sensor-marshal/sensors/use-mouse-sensor.ts @@ -21,6 +21,7 @@ import preventStandardKeyEvents from './util/prevent-standard-key-events'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; import useLayoutEffect from '../../use-isomorphic-layout-effect'; import { noop } from '../../../empty'; +import offsetPoint from './util/offset-point'; // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button export const primaryButton = 0; @@ -72,15 +73,16 @@ function getCaptureBindings({ { eventName: 'mousemove', fn: (event: MouseEvent) => { - const { button, clientX, clientY } = event; + const { button } = event; if (button !== primaryButton) { return; } - const point: Position = { - x: clientX, - y: clientY, - }; + const point = offsetPoint( + event.clientX, + event.clientY, + event.currentTarget as Window, + ); const phase: Phase = getPhase(); @@ -252,10 +254,11 @@ export default function useMouseSensor(api: SensorAPI) { // consuming the event event.preventDefault(); - const point: Position = { - x: event.clientX, - y: event.clientY, - }; + const point = offsetPoint( + event.clientX, + event.clientY, + event.currentTarget as Window, + ); // unbind this listener unbindEventsRef.current(); diff --git a/src/view/use-sensor-marshal/sensors/use-touch-sensor.ts b/src/view/use-sensor-marshal/sensors/use-touch-sensor.ts index 73b07a101..34feafd65 100644 --- a/src/view/use-sensor-marshal/sensors/use-touch-sensor.ts +++ b/src/view/use-sensor-marshal/sensors/use-touch-sensor.ts @@ -18,6 +18,7 @@ import * as keyCodes from '../../key-codes'; import supportedPageVisibilityEventName from './util/supported-page-visibility-event-name'; import { noop } from '../../../empty'; import useLayoutEffect from '../../use-isomorphic-layout-effect'; +import offsetPoint from './util/offset-point'; type TouchWithForce = Touch & { force: number; @@ -134,10 +135,11 @@ function getHandleBindings({ const { clientX, clientY } = event.touches[0]; - const point: Position = { - x: clientX, - y: clientY, - }; + const point = offsetPoint( + clientX, + clientY, + event.currentTarget as Window, + ); // We need to prevent the default event in order to block native scrolling // Also because we are using it as part of a drag we prevent the default action @@ -289,10 +291,12 @@ export default function useTouchSensor(api: SensorAPI) { const touch: Touch = event.touches[0]; const { clientX, clientY } = touch; - const point: Position = { - x: clientX, - y: clientY, - }; + + const point = offsetPoint( + clientX, + clientY, + event.currentTarget as Window, + ); // unbind this event handler unbindEventsRef.current(); diff --git a/src/view/use-sensor-marshal/sensors/util/offset-point.ts b/src/view/use-sensor-marshal/sensors/util/offset-point.ts new file mode 100644 index 000000000..35a80d66b --- /dev/null +++ b/src/view/use-sensor-marshal/sensors/util/offset-point.ts @@ -0,0 +1,25 @@ +import { Position } from 'css-box-model'; + +export default function offsetPoint(x: number, y: number, win: Window) { + // const { clientX, clientY } = event; + + // const win = event.currentTarget as Window; + + let offsetX = 0; + let offsetY = 0; + + if (win.parent !== win.self) { + const iframe = win.frameElement as HTMLIFrameElement; + const rect = iframe.getBoundingClientRect(); + + offsetX = rect.left; + offsetY = rect.top; + } + + const point: Position = { + x: x + offsetX, + y: y + offsetY, + }; + + return point; +} diff --git a/src/view/use-style-marshal/use-style-marshal.ts b/src/view/use-style-marshal/use-style-marshal.ts index 7b2e64703..672773bc3 100644 --- a/src/view/use-style-marshal/use-style-marshal.ts +++ b/src/view/use-style-marshal/use-style-marshal.ts @@ -1,4 +1,3 @@ -import { useRef, MutableRefObject } from 'react'; import memoizeOne from 'memoize-one'; import { useMemo, useCallback } from 'use-memo-one'; import { invariant } from '../../invariant'; @@ -8,10 +7,11 @@ import getStyles from './get-styles'; import type { Styles } from './get-styles'; import { prefix } from '../data-attributes'; import useLayoutEffect from '../use-isomorphic-layout-effect'; +import { querySelectorAll } from '../../query-selector-all'; +import querySelectorAllIframe from '../iframe/query-selector-all-iframe'; -const getHead = (): HTMLHeadElement => { - const head: HTMLHeadElement | null = document.querySelector('head'); - invariant(head, 'Cannot find the head to append a style to'); +const getHead = (doc: Document): HTMLHeadElement | null => { + const head: HTMLHeadElement | null = doc.querySelector('head'); return head; }; @@ -24,64 +24,97 @@ const createStyleEl = (nonce?: string): HTMLStyleElement => { return el; }; +const alwaysDataAttr = `${prefix}-always`; +const dynamicDataAttr = `${prefix}-dynamic`; + export default function useStyleMarshal(contextId: ContextId, nonce?: string) { const styles: Styles = useMemo(() => getStyles(contextId), [contextId]); - const alwaysRef = useRef(null); - const dynamicRef = useRef(null); // eslint-disable-next-line react-hooks/exhaustive-deps const setDynamicStyle = useCallback( // Using memoizeOne to prevent frequent updates to textContext memoizeOne((proposed: string) => { - const el: HTMLStyleElement | null = dynamicRef.current; - invariant(el, 'Cannot set dynamic style element if it is not set'); - el.textContent = proposed; + const selector = `[${dynamicDataAttr}="${contextId}"]`; + + querySelectorAllIframe(selector).forEach((el) => { + invariant(el, 'Cannot set dynamic style element if it is not set'); + el.textContent = proposed; + }); }), - [], + [contextId], ); - const setAlwaysStyle = useCallback((proposed: string) => { - const el: HTMLStyleElement | null = alwaysRef.current; - invariant(el, 'Cannot set dynamic style element if it is not set'); - el.textContent = proposed; - }, []); + const setAlwaysStyle = useCallback( + (proposed: string) => { + const selector = `[${alwaysDataAttr}="${contextId}"]`; + + querySelectorAllIframe(selector).forEach((el) => { + invariant(el, 'Cannot set dynamic style element if it is not set'); + el.textContent = proposed; + }); + }, + [contextId], + ); // using layout effect as programatic dragging might start straight away (such as for cypress) useLayoutEffect(() => { - invariant( - !alwaysRef.current && !dynamicRef.current, - 'style elements already mounted', - ); + const alwaysSelector = `[${alwaysDataAttr}="${contextId}"]`; + const dynamicSelector = `[${dynamicDataAttr}="${contextId}"]`; + + const heads = [ + getHead(document), + ...( + querySelectorAll(document, `[${prefix}-iframe]`) as HTMLIFrameElement[] + ).map((iframe) => getHead(iframe.contentWindow!.document)), + ]; + + // Create initial style elements + heads.forEach((head) => { + if (!head) return; + + const alwaysElements = querySelectorAll( + head.ownerDocument, + alwaysSelector, + ); + const dynamicElements = querySelectorAll( + head.ownerDocument, + dynamicSelector, + ); + + if ( + alwaysElements.length >= heads.length || + dynamicElements.length >= heads.length + ) { + return; + } - const always: HTMLStyleElement = createStyleEl(nonce); - const dynamic: HTMLStyleElement = createStyleEl(nonce); + const always: HTMLStyleElement = createStyleEl(nonce); + const dynamic: HTMLStyleElement = createStyleEl(nonce); - // store their refs - alwaysRef.current = always; - dynamicRef.current = dynamic; + // for easy identification + always.setAttribute(alwaysDataAttr, contextId); + dynamic.setAttribute(dynamicDataAttr, contextId); - // for easy identification - always.setAttribute(`${prefix}-always`, contextId); - dynamic.setAttribute(`${prefix}-dynamic`, contextId); + head.appendChild(always); + head.appendChild(dynamic); - // add style tags to head - getHead().appendChild(always); - getHead().appendChild(dynamic); - - // set initial style - setAlwaysStyle(styles.always); - setDynamicStyle(styles.resting); + // set initial style + setAlwaysStyle(styles.always); + setDynamicStyle(styles.resting); + }); return () => { - const remove = (ref: MutableRefObject) => { - const current: HTMLStyleElement | null = ref.current; - invariant(current, 'Cannot unmount ref as it is not set'); - getHead().removeChild(current); - ref.current = null; + const remove = (selector: string) => { + const elements = querySelectorAllIframe(selector); + + elements.forEach((el) => { + invariant(el, 'Cannot unmount element as it is not set'); + el.ownerDocument.head.removeChild(el); + }); }; - remove(alwaysRef); - remove(dynamicRef); + remove(alwaysSelector); + remove(dynamicSelector); }; }, [ nonce, @@ -107,10 +140,6 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) { [setDynamicStyle, styles.dropAnimating, styles.userCancel], ); const resting = useCallback(() => { - // Can be called defensively - if (!dynamicRef.current) { - return; - } setDynamicStyle(styles.resting); }, [setDynamicStyle, styles.resting]); diff --git a/src/view/window/get-viewport.ts b/src/view/window/get-viewport.ts index 0b7d8f6a7..1c98c7f3a 100644 --- a/src/view/window/get-viewport.ts +++ b/src/view/window/get-viewport.ts @@ -1,12 +1,16 @@ import { getRect } from 'css-box-model'; import type { Rect, Position } from 'css-box-model'; -import type { Viewport } from '../../types'; +import type { Critical, Viewport } from '../../types'; import { origin } from '../../state/position'; import getWindowScroll from './get-window-scroll'; import getMaxWindowScroll from './get-max-window-scroll'; import getDocumentElement from '../get-document-element'; +import getIframeOffset from '../iframe/get-iframe-offset'; +import querySelectorAllIframe from '../iframe/query-selector-all-iframe'; +import { prefix } from '../data-attributes'; +import applyOffset from '../iframe/apply-offset'; -export default (): Viewport => { +export default (critical?: Critical): Viewport => { const scroll: Position = getWindowScroll(); const maxScroll: Position = getMaxWindowScroll(); @@ -32,6 +36,13 @@ export default (): Viewport => { bottom, }); + const droppables = querySelectorAllIframe( + `[${prefix}-droppable-id="${critical?.droppable.id}"]`, + ); + + const offset = getIframeOffset(droppables[0]); + const offsetFrame = applyOffset(frame, offset); + const viewport: Viewport = { frame, scroll: { @@ -43,6 +54,7 @@ export default (): Viewport => { displacement: origin, }, }, + offset: offsetFrame, }; return viewport; diff --git a/stories/examples/61-iframe.stories.tsx b/stories/examples/61-iframe.stories.tsx new file mode 100644 index 000000000..9860abdfc --- /dev/null +++ b/stories/examples/61-iframe.stories.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import IframeBoard from '../src/board/iframe-board'; +import { authorQuoteMap } from '../src/data'; + +storiesOf('Examples/iframe', module).add('simple', () => ( + ({ + ...acc, + [author]: authorQuoteMap[author].map((quote) => ({ + ...quote, + author: { ...quote.author, url: '' }, + })), + }), + {}, + )} + /> +)); diff --git a/stories/src/board/column.tsx b/stories/src/board/column.tsx index 3c8ffdebc..420a4fd73 100644 --- a/stories/src/board/column.tsx +++ b/stories/src/board/column.tsx @@ -51,7 +51,7 @@ export default class Column extends Component { const quotes: Quote[] = this.props.quotes; const index: number = this.props.index; return ( - + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
diff --git a/stories/src/board/iframe-board.tsx b/stories/src/board/iframe-board.tsx new file mode 100644 index 000000000..025f86d92 --- /dev/null +++ b/stories/src/board/iframe-board.tsx @@ -0,0 +1,145 @@ +import React, { Component, ReactElement } from 'react'; +import styled from '@emotion/styled'; +import { Global, css } from '@emotion/react'; +import { colors } from '@atlaskit/theme'; +import type { + DropResult, + DraggableLocation, + DroppableProvided, +} from '@hello-pangea/dnd'; +import { DragDropContext, Droppable } from '@hello-pangea/dnd'; +import AutoFrame from '@measured/auto-frame-component'; +import type { QuoteMap } from '../types'; +import { reorderQuoteMap } from '../reorder'; +import { PartialAutoScrollerOptions } from '../../../src/state/auto-scroller/fluid-scroller/auto-scroller-options-types'; +import QuoteList from '../primatives/quote-list'; +import Title from '../primatives/title'; +import { grid, borderRadius } from '../constants'; + +const Column = styled.div` + margin: ${grid}px; + display: flex; + flex-direction: column; +`; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: center; + border-top-left-radius: ${borderRadius}px; + border-top-right-radius: ${borderRadius}px; + background-color: ${colors.N30}; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${colors.G50}; + } +`; + +interface Props { + initial: QuoteMap; + withScrollableColumns?: boolean; + isCombineEnabled?: boolean; + containerHeight?: string; + useClone?: boolean; + applyGlobalStyles?: boolean; + autoScrollerOptions?: PartialAutoScrollerOptions; +} + +interface State { + columns: QuoteMap; + ordered: string[]; +} + +export default class IframeBoard extends Component { + /* eslint-disable react/sort-comp */ + static defaultProps = { + isCombineEnabled: false, + applyGlobalStyles: true, + }; + + state: State = { + columns: this.props.initial, + ordered: Object.keys(this.props.initial), + }; + + onDragEnd = (result: DropResult): void => { + // dropped nowhere + if (!result.destination) { + return; + } + + const source: DraggableLocation = result.source; + const destination: DraggableLocation = result.destination; + + // did not move anywhere - can bail early + if ( + source.droppableId === destination.droppableId && + source.index === destination.index + ) { + return; + } + + const data = reorderQuoteMap({ + quoteMap: this.state.columns, + source, + destination, + }); + + this.setState({ + columns: data.quoteMap, + }); + }; + + render(): ReactElement { + const columns: QuoteMap = this.state.columns; + const ordered: string[] = this.state.ordered; + + const board = ordered.map((key: string, index: number) => ( + + + {(provided: DroppableProvided) => ( + +
+ + {key} (iframe {index}) + +
+ + {provided.placeholder} +
+ )} +
+
+ )); + + return ( + + +
{board}
+
+ +
+ ); + } +} diff --git a/stories/src/primatives/quote-item.tsx b/stories/src/primatives/quote-item.tsx index ad9c82c1d..0a0b62519 100644 --- a/stories/src/primatives/quote-item.tsx +++ b/stories/src/primatives/quote-item.tsx @@ -182,7 +182,7 @@ function QuoteItem(props: Props) { return ( Date: Sun, 25 Feb 2024 18:27:57 +0100 Subject: [PATCH 02/16] feat: add shouldRenderOriginal prop to enable original to be retained when cloning --- docs/api/droppable.md | 1 + src/view/context/droppable-context.ts | 1 + src/view/draggable/draggable-api.tsx | 4 +++- src/view/droppable/droppable-types.ts | 2 ++ src/view/droppable/droppable.tsx | 4 +++- 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/api/droppable.md b/docs/api/droppable.md index e04812a3c..50533c343 100644 --- a/docs/api/droppable.md +++ b/docs/api/droppable.md @@ -58,6 +58,7 @@ type Direction = 'horizontal' | 'vertical'; - `ignoreContainerClipping`: When a `` is inside a scrollable container its area is constrained so that you can only drop on the part of the `` that you can see. Setting this prop opts out of this behavior, allowing you to drop anywhere on a `` even if it's visually hidden by a scrollable parent. The default behavior is suitable for most cases so odds are you'll never need to use this prop, but it can be useful if you've got very long ``s inside a short scroll container. Keep in mind that it might cause some unexpected behavior if you have multiple ``s inside scroll containers on the same page. - `mode`: `standard` (default) or `virtual`. Used to designate a list as a virtual list. See our [virtual lists pattern](/docs/patterns/virtual-lists.md) - `renderClone`: used to render a clone (replacement) of the dragging `` while a drag is occurring. See our [reparenting guide](/docs/guides/reparenting.md) for usage details. **A clone must be used for [virtual lists](/docs/patterns/virtual-lists.md).** You can use a clone without using virtual lists +- `shouldRenderOriginal`: keep the original draggable element mounted when using the `renderClone` API. - `getContainerForClone`: a function that returns the containing element (parent element) for a clone during a drag. See our [reparenting guide](/docs/guides/reparenting.md). ## Children function diff --git a/src/view/context/droppable-context.ts b/src/view/context/droppable-context.ts index 2f12b6388..8a27f6388 100644 --- a/src/view/context/droppable-context.ts +++ b/src/view/context/droppable-context.ts @@ -5,6 +5,7 @@ export interface DroppableContextValue { isUsingCloneFor: DraggableId | null; droppableId: DroppableId; type: TypeId; + shouldRenderOriginal: boolean; } export default React.createContext(null); diff --git a/src/view/draggable/draggable-api.tsx b/src/view/draggable/draggable-api.tsx index aef623034..c2c525218 100644 --- a/src/view/draggable/draggable-api.tsx +++ b/src/view/draggable/draggable-api.tsx @@ -16,7 +16,9 @@ export function PrivateDraggable(props: PrivateOwnProps) { // In that case we unmount the existing dragging item const isUsingCloneFor: DraggableId | null = droppableContext.isUsingCloneFor; if (isUsingCloneFor === props.draggableId && !props.isClone) { - return null; + if (!droppableContext.shouldRenderOriginal) { + return null; + } } return ; diff --git a/src/view/droppable/droppable-types.ts b/src/view/droppable/droppable-types.ts index 6a6955fa9..1860057b5 100644 --- a/src/view/droppable/droppable-types.ts +++ b/src/view/droppable/droppable-types.ts @@ -55,6 +55,7 @@ export interface MapProps { // snapshot based on redux state to be provided to consumers snapshot: DroppableStateSnapshot; useClone: UseClone | null; + shouldRenderOriginal?: boolean; } export interface DefaultProps { @@ -79,6 +80,7 @@ export interface DroppableProps extends Partial { ) => ReactNode; droppableId: DroppableId; renderClone?: DraggableChildrenFn | null; + shouldRenderOriginal?: boolean; } export type InternalOwnProps = DroppableProps & diff --git a/src/view/droppable/droppable.tsx b/src/view/droppable/droppable.tsx index d606d9651..4e6ed8f58 100644 --- a/src/view/droppable/droppable.tsx +++ b/src/view/droppable/droppable.tsx @@ -44,6 +44,7 @@ const Droppable: FunctionComponent = (props) => { // map props snapshot, useClone, + shouldRenderOriginal = false, // dispatch props updateViewportMaxScroll, @@ -139,8 +140,9 @@ const Droppable: FunctionComponent = (props) => { droppableId, type, isUsingCloneFor, + shouldRenderOriginal, }), - [droppableId, isUsingCloneFor, type], + [droppableId, isUsingCloneFor, shouldRenderOriginal, type], ); function getClone(): ReactNode | null { From df13d294757467e5a49f1e9cbcf8d0165523c974 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Wed, 28 Feb 2024 18:49:04 +0100 Subject: [PATCH 03/16] refactor: support scroll while dragging in an iframe --- .../get-closest-scrollable.ts | 24 +++++++++++++------ .../use-droppable-publisher/get-scroll.ts | 18 ++++++++++---- .../use-droppable-publisher/get-scrollable.ts | 9 +++++++ .../use-droppable-publisher.ts | 7 +++--- 4 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 src/view/use-droppable-publisher/get-scrollable.ts diff --git a/src/view/use-droppable-publisher/get-closest-scrollable.ts b/src/view/use-droppable-publisher/get-closest-scrollable.ts index 5ac5f4e27..827dc069c 100644 --- a/src/view/use-droppable-publisher/get-closest-scrollable.ts +++ b/src/view/use-droppable-publisher/get-closest-scrollable.ts @@ -20,7 +20,8 @@ const isBoth = (overflow: Overflow, fn: (value: string) => boolean) => fn(overflow.overflowX) && fn(overflow.overflowY); const isElementScrollable = (el: Element): boolean => { - const style: CSSStyleDeclaration = window.getComputedStyle(el); + const style: CSSStyleDeclaration = + el.ownerDocument.defaultView!.getComputedStyle(el); const overflow: Overflow = { overflowX: style.overflowX, overflowY: style.overflowY, @@ -31,14 +32,14 @@ const isElementScrollable = (el: Element): boolean => { // Special case for a body element // Playground: https://codepen.io/alexreardon/pen/ZmyLgX?editors=1111 -const isBodyScrollable = (): boolean => { +const isBodyScrollable = (el: HTMLElement): boolean => { // Because we always return false for now, we can skip any actual processing in production if (process.env.NODE_ENV === 'production') { return false; } const body: HTMLBodyElement = getBodyElement(); - const html: HTMLElement | null = document.documentElement; + const html: HTMLElement | null = el.ownerDocument.documentElement; invariant(html); // 1. The `body` has `overflow-[x|y]: auto | scroll` @@ -46,7 +47,8 @@ const isBodyScrollable = (): boolean => { return false; } - const htmlStyle: CSSStyleDeclaration = window.getComputedStyle(html); + const htmlStyle: CSSStyleDeclaration = + el.ownerDocument.defaultView!.getComputedStyle(html); const htmlOverflow: Overflow = { overflowX: htmlStyle.overflowX, overflowY: htmlStyle.overflowY, @@ -76,12 +78,20 @@ const getClosestScrollable = (el?: HTMLElement | null): HTMLElement | null => { } // not allowing us to go higher then body - if (el === document.body) { - return isBodyScrollable() ? el : null; + if (el === el.ownerDocument.body) { + if (isBodyScrollable(el)) { + return el; + } + + if (el.ownerDocument.defaultView?.frameElement) { + return el.ownerDocument.defaultView?.frameElement as HTMLElement; + } + + return null; } // Should never get here, but just being safe - if (el === document.documentElement) { + if (el === el.ownerDocument.documentElement) { return null; } diff --git a/src/view/use-droppable-publisher/get-scroll.ts b/src/view/use-droppable-publisher/get-scroll.ts index e9469aab3..20682e756 100644 --- a/src/view/use-droppable-publisher/get-scroll.ts +++ b/src/view/use-droppable-publisher/get-scroll.ts @@ -1,6 +1,16 @@ import type { Position } from 'css-box-model'; -export default (el: Element): Position => ({ - x: el.scrollLeft, - y: el.scrollTop, -}); +export default (el: Element): Position => { + const isIframe = el.tagName === 'IFRAME'; + + if (isIframe) { + const targetEl = (el as HTMLIFrameElement).contentWindow!; + + return { x: targetEl.scrollX, y: targetEl.scrollY }; + } + + return { + x: el.scrollLeft, + y: el.scrollTop, + }; +}; diff --git a/src/view/use-droppable-publisher/get-scrollable.ts b/src/view/use-droppable-publisher/get-scrollable.ts new file mode 100644 index 000000000..717d91256 --- /dev/null +++ b/src/view/use-droppable-publisher/get-scrollable.ts @@ -0,0 +1,9 @@ +export default (scrollable: HTMLElement) => { + const scrollableIsIframe = scrollable.tagName === 'IFRAME'; + + const scrollableTarget = scrollableIsIframe + ? (scrollable as HTMLIFrameElement).contentWindow! + : scrollable; + + return scrollableTarget; +}; diff --git a/src/view/use-droppable-publisher/use-droppable-publisher.ts b/src/view/use-droppable-publisher/use-droppable-publisher.ts index 82ae4e805..1c3522c97 100644 --- a/src/view/use-droppable-publisher/use-droppable-publisher.ts +++ b/src/view/use-droppable-publisher/use-droppable-publisher.ts @@ -33,6 +33,7 @@ import useRequiredContext from '../use-required-context'; import usePreviousRef from '../use-previous-ref'; import useLayoutEffect from '../use-isomorphic-layout-effect'; import useUniqueId from '../use-unique-id'; +import getScrollable from './get-scrollable'; interface Props { droppableId: DroppableId; @@ -153,7 +154,7 @@ export default function useDroppablePublisher(args: Props) { shouldClipSubject: !previous.ignoreContainerClipping, }); - const scrollable: Element | null = env.closestScrollable; + const scrollable = env.closestScrollable; if (scrollable) { scrollable.setAttribute( @@ -162,7 +163,7 @@ export default function useDroppablePublisher(args: Props) { ); // bind scroll listener - scrollable.addEventListener( + getScrollable(scrollable).addEventListener( 'scroll', onClosestScroll, getListenerOptions(dragging.scrollOptions), @@ -204,7 +205,7 @@ export default function useDroppablePublisher(args: Props) { // unwatch scroll scheduleScrollUpdate.cancel(); closest.removeAttribute(dataAttr.scrollContainer.contextId); - closest.removeEventListener( + getScrollable(closest).removeEventListener( 'scroll', onClosestScroll, // See: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener From 28feafb52b758c11203675480983e74d8e68766f Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Wed, 28 Feb 2024 19:43:16 +0100 Subject: [PATCH 04/16] refactor: remove keyboard movement offsetting, as only compatible withn cloning Legacy from when iframe support required renderClone. --- src/state/move-in-direction/move-cross-axis/index.ts | 11 ++--------- .../move-in-direction/move-to-next-place/index.ts | 11 ++--------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/state/move-in-direction/move-cross-axis/index.ts b/src/state/move-in-direction/move-cross-axis/index.ts index 2deb7e8b4..f15ff1427 100644 --- a/src/state/move-in-direction/move-cross-axis/index.ts +++ b/src/state/move-in-direction/move-cross-axis/index.ts @@ -1,4 +1,4 @@ -import type { Position } from 'css-box-model'; +import { type Position } from 'css-box-model'; import type { PublicResult } from '../move-in-direction-types'; import type { DroppableDimension, @@ -16,7 +16,6 @@ import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable' import getClientFromPageBorderBoxCenter from '../../get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center'; import getPageBorderBoxCenter from '../../get-center-from-impact/get-page-border-box-center'; import moveToNewDroppable from './move-to-new-droppable'; -import { subtract } from '../../position'; interface Args { isMovingForward: boolean; @@ -101,14 +100,8 @@ export default ({ viewport, }); - // Offset viewport when moving along axis (such as for using keyboard sensor with iframes) - const offsetClientSelection = subtract(clientSelection, { - x: viewport.offset.x, - y: viewport.offset.y, - }); - return { - clientSelection: offsetClientSelection, + clientSelection, impact, scrollJumpRequest: null, }; diff --git a/src/state/move-in-direction/move-to-next-place/index.ts b/src/state/move-in-direction/move-to-next-place/index.ts index 502213ea3..707dda941 100644 --- a/src/state/move-in-direction/move-to-next-place/index.ts +++ b/src/state/move-in-direction/move-to-next-place/index.ts @@ -15,7 +15,7 @@ import isHomeOf from '../../droppable/is-home-of'; import getPageBorderBoxCenter from '../../get-center-from-impact/get-page-border-box-center'; import speculativelyIncrease from '../../update-displacement-visibility/speculatively-increase'; import getClientFromPageBorderBoxCenter from '../../get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center'; -import { subtract } from '../../position'; +import { add, subtract } from '../../position'; import isTotallyVisibleInNewLocation from './is-totally-visible-in-new-location'; interface Args { @@ -104,14 +104,8 @@ export default ({ viewport, }); - // Offset viewport when moving along axis (such as for using keyboard sensor with iframes) - const offsetClientSelection = subtract(clientSelection, { - x: viewport.offset.x, - y: viewport.offset.y, - }); - return { - clientSelection: offsetClientSelection, + clientSelection, impact, scrollJumpRequest: null, }; @@ -129,7 +123,6 @@ export default ({ draggables, maxScrollChange: distance, }); - return { clientSelection: previousClientSelection, impact: cautious, From c882d9871ac260070c07bffd02bf4bb3e6883e6f Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Mon, 4 Mar 2024 18:49:50 +0100 Subject: [PATCH 05/16] feat: add CSS transform support --- src/state/droppable/get-droppable.ts | 4 + src/types.ts | 4 + src/view/draggable/connected-draggable.ts | 34 ++- src/view/draggable/draggable-types.ts | 3 + src/view/draggable/get-style.ts | 24 +- src/view/iframe/apply-offset.ts | 12 +- src/view/iframe/get-iframe-offset.ts | 7 +- src/view/iframe/get-iframe.ts | 11 + src/view/transform/index.ts | 269 ++++++++++++++++++ .../use-draggable-publisher/get-dimension.ts | 33 ++- .../use-droppable-publisher/get-dimension.ts | 23 +- .../use-droppable-publisher/get-scroll.ts | 10 +- .../sensors/util/offset-point.ts | 27 +- stories/examples/61-iframe.stories.tsx | 44 ++- stories/src/board/iframe-board.tsx | 7 +- 15 files changed, 467 insertions(+), 45 deletions(-) create mode 100644 src/view/iframe/get-iframe.ts create mode 100644 src/view/transform/index.ts diff --git a/src/state/droppable/get-droppable.ts b/src/state/droppable/get-droppable.ts index 3226259e2..1c72f93a2 100644 --- a/src/state/droppable/get-droppable.ts +++ b/src/state/droppable/get-droppable.ts @@ -11,6 +11,7 @@ import { vertical, horizontal } from '../axis'; import { origin } from '../position'; import getMaxScroll from '../get-max-scroll'; import getSubject from './util/get-subject'; +import { Transform } from '../../view/transform'; export interface Closest { client: BoxModel; @@ -30,6 +31,7 @@ interface Args { // is null when in a fixed container page: BoxModel; closest?: Closest | null; + transform: Transform | null; } export default ({ @@ -41,6 +43,7 @@ export default ({ client, page, closest, + transform, }: Args): DroppableDimension => { const frame: Scrollable | null = (() => { if (!closest) { @@ -94,6 +97,7 @@ export default ({ page, frame, subject, + transform, }; return dimension; diff --git a/src/types.ts b/src/types.ts index eb48ad45b..3ee3b4a6a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { BoxModel, Rect, Position } from 'css-box-model'; +import { Transform } from './view/transform'; export type Id = string; export type DraggableId = Id; @@ -97,6 +98,8 @@ export interface DraggableDimension { // how much displacement the draggable causes // this is the size of the marginBox displaceBy: Position; + // Any transforms applied to this draggable + transform: Transform | null; } export interface Scrollable { @@ -150,6 +153,7 @@ export interface DroppableDimension { frame: Scrollable | null; // what is visible through the frame subject: DroppableSubject; + transform: Transform | null; } export interface DraggableLocation { droppableId: DroppableId; diff --git a/src/view/draggable/connected-draggable.ts b/src/view/draggable/connected-draggable.ts index e763bd7af..b17bb3347 100644 --- a/src/view/draggable/connected-draggable.ts +++ b/src/view/draggable/connected-draggable.ts @@ -20,6 +20,7 @@ import type { DropResult, LiftEffect, Combine, + DroppableDimension, } from '../../types'; import type { DraggableProps, @@ -214,6 +215,7 @@ const atRest: MapProps = { combineTargetFor: null, shouldAnimateDisplacement: true, snapshot: getSecondarySnapshot(null), + sourceDroppable: null, }, }; @@ -233,6 +235,8 @@ function getSecondarySelector(): TrySelect { // eslint-disable-next-line default-param-last combineTargetFor: DraggableId | null = null, shouldAnimateDisplacement: boolean, + dimension?: DraggableDimension, + sourceDroppable?: DroppableDimension | null, ): MapProps => ({ mapped: { type: 'SECONDARY', @@ -240,6 +244,8 @@ function getSecondarySelector(): TrySelect { combineTargetFor, shouldAnimateDisplacement, snapshot: getMemoizedSnapshot(combineTargetFor), + dimension, + sourceDroppable: sourceDroppable || null, }, }), ); @@ -259,6 +265,8 @@ function getSecondarySelector(): TrySelect { draggingId: DraggableId, impact: DragImpact, afterCritical: LiftEffect, + dimension?: DraggableDimension, + sourceDroppable?: DroppableDimension | null, ): MapProps | null => { const visualDisplacement: Displacement | null = impact.displaced.visible[ownId]; @@ -289,7 +297,13 @@ function getSecondarySelector(): TrySelect { // We need to move backwards to close the gap that the dragging item has left const change: Position = negate(afterCritical.displacedBy.point); const offset: Position = memoizedOffset(change.x, change.y); - return getMemoizedProps(offset, combineTargetFor, true); + return getMemoizedProps( + offset, + combineTargetFor, + true, + dimension, + sourceDroppable, + ); } if (isAfterCriticalInVirtualList) { @@ -305,6 +319,8 @@ function getSecondarySelector(): TrySelect { offset, combineTargetFor, visualDisplacement.shouldAnimate, + dimension, + sourceDroppable, ); }; @@ -319,11 +335,19 @@ function getSecondarySelector(): TrySelect { return null; } + const dimension = state.dimensions.draggables[ownProps.draggableId]; + + const sourceDroppable = state.critical.droppable.id + ? state.dimensions.droppables[state.critical.droppable.id] + : null; + return getProps( ownProps.draggableId, state.critical.draggable.id, state.impact, state.afterCritical, + dimension, + sourceDroppable, ); } @@ -334,11 +358,19 @@ function getSecondarySelector(): TrySelect { if (completed.result.draggableId === ownProps.draggableId) { return null; } + const dimension = state.dimensions.draggables[ownProps.draggableId]; + + const sourceDroppable = completed.critical.droppable.id + ? state.dimensions.droppables[completed.critical.droppable.id] + : null; + return getProps( ownProps.draggableId, completed.result.draggableId, completed.impact, completed.afterCritical, + dimension, + sourceDroppable, ); } diff --git a/src/view/draggable/draggable-types.ts b/src/view/draggable/draggable-types.ts index 1f2d1b070..ce8190f30 100644 --- a/src/view/draggable/draggable-types.ts +++ b/src/view/draggable/draggable-types.ts @@ -13,6 +13,7 @@ import type { ContextId, ElementId, DraggableRubric, + DroppableDimension, } from '../../types'; import { dropAnimationFinished } from '../../state/action-creators'; @@ -139,6 +140,8 @@ export interface SecondaryMapProps { combineTargetFor: DraggableId | null; shouldAnimateDisplacement: boolean; snapshot: DraggableStateSnapshot; + dimension?: DraggableDimension; + sourceDroppable: DroppableDimension | null; } export type MappedProps = DraggingMapProps | SecondaryMapProps; diff --git a/src/view/draggable/get-style.ts b/src/view/draggable/get-style.ts index 5e0b1b751..f83320f6d 100644 --- a/src/view/draggable/get-style.ts +++ b/src/view/draggable/get-style.ts @@ -59,22 +59,27 @@ function getDraggingStyle(dragging: DraggingMapProps): DraggingStyle { const shouldAnimate: boolean = getShouldDraggingAnimate(dragging); const isDropAnimating = Boolean(dropping); + const untransformedOffset = { + x: offset.x / (dimension?.transform?.matrix.scaleX || 1), + y: offset.y / (dimension?.transform?.matrix.scaleY || 1), + }; + const transform: string | undefined = isDropAnimating - ? transforms.drop(offset, isCombining) - : transforms.moveTo(offset); + ? transforms.drop(untransformedOffset, isCombining) + : transforms.moveTo(untransformedOffset); const style: DraggingStyle = { // ## Placement position: 'fixed', // As we are applying the margins we need to align to the start of the marginBox - top: box.marginBox.top, - left: box.marginBox.left, + top: box.marginBox.top / (dimension.transform?.matrix.scaleX || 1), + left: box.marginBox.left / (dimension.transform?.matrix.scaleY || 1), // ## Sizing // Locking these down as pulling the node out of the DOM could cause it to change size boxSizing: 'border-box', - width: box.borderBox.width, - height: box.borderBox.height, + width: box.borderBox.width / (dimension.transform?.matrix.scaleX || 1), + height: box.borderBox.height / (dimension.transform?.matrix.scaleY || 1), // ## Movement // Opting out of the standard css transition for the dragging item @@ -94,8 +99,13 @@ function getDraggingStyle(dragging: DraggingMapProps): DraggingStyle { } function getSecondaryStyle(secondary: SecondaryMapProps): NotDraggingStyle { + const { offset, sourceDroppable } = secondary; + return { - transform: transforms.moveTo(secondary.offset), + transform: transforms.moveTo({ + x: offset.x / (sourceDroppable?.transform?.matrix.scaleX || 1), + y: offset.y / (sourceDroppable?.transform?.matrix.scaleY || 1), + }), // transition style is applied in the head transition: secondary.shouldAnimateDisplacement ? undefined : 'none', }; diff --git a/src/view/iframe/apply-offset.ts b/src/view/iframe/apply-offset.ts index d3959a90a..2abc3692c 100644 --- a/src/view/iframe/apply-offset.ts +++ b/src/view/iframe/apply-offset.ts @@ -1,4 +1,4 @@ -import { Rect } from 'css-box-model'; +import { BoxModel, Rect } from 'css-box-model'; import { Offset } from './offset-types'; export default function applyOffset(rect: Partial, offset: Offset): Rect { @@ -18,3 +18,13 @@ export default function applyOffset(rect: Partial, offset: Offset): Rect { height: rect.height || 0, }; } + +export const applyOffsetBox = (box: BoxModel, offset: Offset): BoxModel => { + return { + ...box, + borderBox: applyOffset(box.borderBox, offset), + marginBox: applyOffset(box.marginBox, offset), + paddingBox: applyOffset(box.paddingBox, offset), + contentBox: applyOffset(box.contentBox, offset), + }; +}; diff --git a/src/view/iframe/get-iframe-offset.ts b/src/view/iframe/get-iframe-offset.ts index 7802e35ef..86cfc066c 100644 --- a/src/view/iframe/get-iframe-offset.ts +++ b/src/view/iframe/get-iframe-offset.ts @@ -1,3 +1,4 @@ +import getIframe from './get-iframe'; import { Offset } from './offset-types'; export default function getIframeOffset(el: HTMLElement) { @@ -8,11 +9,9 @@ export default function getIframeOffset(el: HTMLElement) { right: 0, }; - const refWindow = el.ownerDocument.defaultView; - - if (refWindow && refWindow.self !== refWindow.parent) { - const iframe = refWindow.frameElement as HTMLIFrameElement; + const iframe = getIframe(el); + if (iframe) { const rect = iframe.getBoundingClientRect(); offset.left = rect.left; diff --git a/src/view/iframe/get-iframe.ts b/src/view/iframe/get-iframe.ts new file mode 100644 index 000000000..361f7cf8e --- /dev/null +++ b/src/view/iframe/get-iframe.ts @@ -0,0 +1,11 @@ +export default function getIframe(el: HTMLElement) { + const refWindow = el.ownerDocument.defaultView; + + if (refWindow && refWindow.self !== refWindow.parent) { + const iframe = refWindow.frameElement as HTMLIFrameElement; + + return iframe; + } + + return null; +} diff --git a/src/view/transform/index.ts b/src/view/transform/index.ts new file mode 100644 index 000000000..fcbc8e8aa --- /dev/null +++ b/src/view/transform/index.ts @@ -0,0 +1,269 @@ +import { BoxModel, Rect, Spacing } from 'css-box-model'; + +const createCol = (name: string) => `((?<${name}>-?((\\d|\\.)+))(,\\s)?)`; + +const matrixPattern = new RegExp( + `^matrix\\(${createCol('scaleX')}${createCol('skewY')}${createCol( + 'skewX', + )}${createCol('scaleY')}${createCol('translateX')}${createCol( + 'translateY', + )}\\)`, +); + +const originPattern = /(?(\d|\.)+)px (?(\d|\.)+)px/; + +export interface Matrix { + scaleX: T; + skewY: T; + skewX: T; + scaleY: T; + translateX: T; + translateY: T; +} + +export interface Origin { + x: T; + y: T; +} + +export interface Transform { + matrix: Matrix; + origin: Origin; +} + +export const getMatrix = (transform: string): Matrix | null => { + const match = matrixPattern.exec(transform); + + if (match?.groups) { + const stringMatrix = match.groups! as unknown as Matrix; + return Object.keys(stringMatrix).reduce( + (acc, key) => ({ ...acc, [key]: Number(match.groups![key]) }), + { + scaleX: 1, + skewY: 0, + skewX: 0, + scaleY: 1, + translateX: 0, + translateY: 0, + }, + ); + } + + return null; +}; + +const USE_GLOBAL_ORIGIN = true; + +export const getOrigin = (transformOrigin: string): Origin | null => { + if (USE_GLOBAL_ORIGIN) { + return { x: 0, y: 0 }; + } + + const match = originPattern.exec(transformOrigin); + + if (match?.groups) { + const stringOrigin = match.groups! as unknown as Origin; + + return Object.keys(stringOrigin).reduce( + (acc, key) => ({ + ...acc, + [key]: Number(match.groups![key].replace('px', '')), + }), + { x: 0, y: 0 }, + ); + } + + return null; +}; + +const findNearestTransform = (el: HTMLElement): HTMLElement | null => { + const styles = window.getComputedStyle(el); + + if (styles.transform !== 'none') { + return el; + } + + if (!el.parentElement) { + const refWindow = el.ownerDocument.defaultView; + + if (refWindow && refWindow.self !== refWindow.parent) { + const iframe = refWindow.frameElement as HTMLIFrameElement; + + return findNearestTransform(iframe); + } + + return null; + } + + return findNearestTransform(el.parentElement); +}; + +const defaultTransform = { + matrix: { + scaleX: 1, + scaleY: 1, + skewX: 0, + skewY: 0, + translateX: 0, + translateY: 0, + }, + origin: { x: 0, y: 0 }, +}; + +/** + * Gets the transform of the element based on transform styles + * applied either directly to the element, or a parent element. + * + * Will only apply the first transform it encounters. + * @param el + * @param origin + * @returns + */ +export const getTransform = ( + el: HTMLElement, + origin?: Origin, +): Transform | null => { + const transformEl = findNearestTransform(el); + + if (!transformEl) return defaultTransform; + + const styles = window.getComputedStyle(transformEl); + + const matrix = getMatrix(styles.transform); + const calculatedOrigin = getOrigin(styles.transformOrigin); + + if (matrix) { + return { + matrix, + origin: origin || calculatedOrigin || { x: 0, y: 0 }, + }; + } + + return defaultTransform; +}; + +export const applyTransformPoint = ( + x: number, + y: number, + transform: Matrix, + origin: Origin, +) => { + return { + x: transform.scaleX * (x - origin.x) + origin.x, + y: transform.scaleY * (y - origin.y) + origin.y, + }; +}; + +export const removeTransformPoint = ( + x: number, + y: number, + transform: Matrix, + origin: Origin, +) => { + return { + x: (x - origin.x) / transform.scaleX + origin.x, + y: (y - origin.y) / transform.scaleY + origin.y, + }; +}; + +export const applyTransformRect = ( + rect: Rect, + transform: Matrix, + origin: Origin, +): Rect => { + const { x, y } = applyTransformPoint(rect.x, rect.y, transform, origin); + + const width = rect.width * transform.scaleX; + const height = rect.height * transform.scaleY; + + const left = x; + const right = x + width; + const top = y; + const bottom = y + height; + + return { + x, + y, + width, + height, + top, + left, + right, + bottom, + center: { + x: (right + left) / 2, + y: (bottom + top) / 2, + }, + }; +}; + +export const applyTransformDOMRect = ( + rect: DOMRect, + transform: Matrix, + origin: Origin, +): DOMRect => { + const { x, y } = applyTransformPoint(rect.x, rect.y, transform, origin); + + const width = rect.width * transform.scaleX; + const height = rect.height * transform.scaleY; + + return new DOMRect(x, y, width, height); +}; + +export const applyTransformSpacing = ( + spacing: Spacing, + transform: Matrix, +): Spacing => { + return { + top: spacing.top * transform.scaleY, + bottom: spacing.bottom * transform.scaleY, + left: spacing.left * transform.scaleY, + right: spacing.right * transform.scaleY, + }; +}; + +export const removeTransformRect = ( + rect: DOMRect | Rect, + transform: Matrix, + origin: Origin, +): DOMRect => { + const { x, y } = removeTransformPoint(rect.x, rect.y, transform, origin); + + return new DOMRect( + x, + y, + rect.width / transform.scaleX, + rect.height / transform.scaleY, + ); +}; + +export const applyTransformBox = ( + box: BoxModel, + transform: Transform, +): BoxModel => { + return { + borderBox: applyTransformRect( + box.borderBox, + transform.matrix, + transform.origin, + ), + marginBox: applyTransformRect( + box.marginBox, + transform.matrix, + transform.origin, + ), + paddingBox: applyTransformRect( + box.paddingBox, + transform.matrix, + transform.origin, + ), + contentBox: applyTransformRect( + box.contentBox, + transform.matrix, + transform.origin, + ), + border: applyTransformSpacing(box.border, transform.matrix), + margin: applyTransformSpacing(box.margin, transform.matrix), + padding: applyTransformSpacing(box.padding, transform.matrix), + }; +}; diff --git a/src/view/use-draggable-publisher/get-dimension.ts b/src/view/use-draggable-publisher/get-dimension.ts index f6d364e7a..ab9e04829 100644 --- a/src/view/use-draggable-publisher/get-dimension.ts +++ b/src/view/use-draggable-publisher/get-dimension.ts @@ -1,5 +1,5 @@ import { calculateBox, withScroll } from 'css-box-model'; -import type { BoxModel, Position } from 'css-box-model'; +import type { Position } from 'css-box-model'; import type { DraggableDescriptor, DraggableDimension, @@ -7,7 +7,8 @@ import type { } from '../../types'; import { origin } from '../../state/position'; import getIframeOffset from '../iframe/get-iframe-offset'; -import applyOffset from '../iframe/apply-offset'; +import { applyTransformBox, getTransform } from '../transform'; +import { applyOffsetBox } from '../iframe/apply-offset'; export default function getDimension( descriptor: DraggableDescriptor, @@ -16,22 +17,31 @@ export default function getDimension( ): DraggableDimension { const computedStyles: CSSStyleDeclaration = window.getComputedStyle(el); - const offset = getIframeOffset(el); - - const client: BoxModel = calculateBox( + const originalClient = calculateBox( el.getBoundingClientRect(), computedStyles, ); - const page: BoxModel = withScroll( - calculateBox( - applyOffset(el.getBoundingClientRect(), offset), - computedStyles, - ), + let client = { ...originalClient }; + let page = withScroll( + calculateBox(el.getBoundingClientRect(), computedStyles), windowScroll, ); + const transform = getTransform(el, { x: 0, y: 0 }); + + if (transform) { + client = applyTransformBox(client, transform); + page = applyTransformBox(page, transform); + } + + const iframeOffset = getIframeOffset(el); + + if (iframeOffset) { + page = applyOffsetBox(page, iframeOffset); + } + const placeholder: Placeholder = { - client, + client: originalClient, tagName: el.tagName.toLowerCase(), display: computedStyles.display, }; @@ -46,6 +56,7 @@ export default function getDimension( displaceBy, client, page, + transform, }; return dimension; diff --git a/src/view/use-droppable-publisher/get-dimension.ts b/src/view/use-droppable-publisher/get-dimension.ts index c70e28d66..26d3449c0 100644 --- a/src/view/use-droppable-publisher/get-dimension.ts +++ b/src/view/use-droppable-publisher/get-dimension.ts @@ -10,13 +10,26 @@ import type { ScrollSize, } from '../../types'; import getScroll from './get-scroll'; -import getOffsettedBox from '../iframe/get-offsetted-box'; +import getIframeOffset from '../iframe/get-iframe-offset'; +import { applyOffsetBox } from '../iframe/apply-offset'; +import { Transform, applyTransformBox, getTransform } from '../transform'; +import { Offset } from '../iframe/offset-types'; const getClient = ( targetRef: HTMLElement, closestScrollable?: Element | null, + offset?: Offset | null, + transform?: Transform | null, ): BoxModel => { - const base: BoxModel = getOffsettedBox(targetRef); + let base: BoxModel = getBox(targetRef); + + if (transform) { + base = applyTransformBox(base, transform); + } + + if (offset) { + base = applyOffsetBox(base, offset); + } // Droppable has no scroll parent if (!closestScrollable) { @@ -80,6 +93,7 @@ interface Args { isDropDisabled: boolean; isCombineEnabled: boolean; shouldClipSubject: boolean; + transform: Transform | null; } export default ({ @@ -93,7 +107,9 @@ export default ({ shouldClipSubject, }: Args): DroppableDimension => { const closestScrollable: Element | null = env.closestScrollable; - const client: BoxModel = getClient(ref, closestScrollable); + const offset = getIframeOffset(ref); + const transform = getTransform(ref, { x: 0, y: 0 }); + const client: BoxModel = getClient(ref, closestScrollable, offset, transform); const page: BoxModel = withScroll(client, windowScroll); const closest: Closest | null = (() => { @@ -125,6 +141,7 @@ export default ({ client, page, closest, + transform, }); return dimension; diff --git a/src/view/use-droppable-publisher/get-scroll.ts b/src/view/use-droppable-publisher/get-scroll.ts index 20682e756..d73fe8c00 100644 --- a/src/view/use-droppable-publisher/get-scroll.ts +++ b/src/view/use-droppable-publisher/get-scroll.ts @@ -1,12 +1,18 @@ import type { Position } from 'css-box-model'; +import { getTransform } from '../transform'; export default (el: Element): Position => { const isIframe = el.tagName === 'IFRAME'; if (isIframe) { - const targetEl = (el as HTMLIFrameElement).contentWindow!; + const win = (el as HTMLIFrameElement).contentWindow!; - return { x: targetEl.scrollX, y: targetEl.scrollY }; + const transform = getTransform(el as HTMLElement); + + return { + x: win.scrollX * (transform?.matrix.scaleX || 1), + y: win.scrollY * (transform?.matrix.scaleY || 1), + }; } return { diff --git a/src/view/use-sensor-marshal/sensors/util/offset-point.ts b/src/view/use-sensor-marshal/sensors/util/offset-point.ts index 35a80d66b..941fead0c 100644 --- a/src/view/use-sensor-marshal/sensors/util/offset-point.ts +++ b/src/view/use-sensor-marshal/sensors/util/offset-point.ts @@ -1,6 +1,11 @@ import { Position } from 'css-box-model'; +import { applyTransformPoint, getTransform } from '../../../transform'; -export default function offsetPoint(x: number, y: number, win: Window) { +export default function offsetPoint( + x: number, + y: number, + win: Window, +): Position { // const { clientX, clientY } = event; // const win = event.currentTarget as Window; @@ -12,8 +17,28 @@ export default function offsetPoint(x: number, y: number, win: Window) { const iframe = win.frameElement as HTMLIFrameElement; const rect = iframe.getBoundingClientRect(); + const transform = getTransform(iframe); + offsetX = rect.left; offsetY = rect.top; + + if (transform) { + const { x: transformedX, y: transformedY } = applyTransformPoint( + x, + y, + transform.matrix, + transform.origin, + ); + + const point: Position = { + x: transformedX + offsetX, + y: transformedY + offsetY, + }; + + console.log(transformedX, transformedY); + + return point; + } } const point: Position = { diff --git a/stories/examples/61-iframe.stories.tsx b/stories/examples/61-iframe.stories.tsx index 9860abdfc..154a1b5f8 100644 --- a/stories/examples/61-iframe.stories.tsx +++ b/stories/examples/61-iframe.stories.tsx @@ -3,17 +3,33 @@ import { storiesOf } from '@storybook/react'; import IframeBoard from '../src/board/iframe-board'; import { authorQuoteMap } from '../src/data'; -storiesOf('Examples/iframe', module).add('simple', () => ( - ({ - ...acc, - [author]: authorQuoteMap[author].map((quote) => ({ - ...quote, - author: { ...quote.author, url: '' }, - })), - }), - {}, - )} - /> -)); +storiesOf('Examples/iframe', module) + .add('simple', () => ( + ({ + ...acc, + [author]: authorQuoteMap[author].map((quote) => ({ + ...quote, + author: { ...quote.author, url: '' }, + })), + }), + {}, + )} + /> + )) + .add('transformed', () => ( + ({ + ...acc, + [author]: authorQuoteMap[author].map((quote) => ({ + ...quote, + author: { ...quote.author, url: '' }, + })), + }), + {}, + )} + /> + )); diff --git a/stories/src/board/iframe-board.tsx b/stories/src/board/iframe-board.tsx index 025f86d92..136e71bc3 100644 --- a/stories/src/board/iframe-board.tsx +++ b/stories/src/board/iframe-board.tsx @@ -44,6 +44,7 @@ interface Props { useClone?: boolean; applyGlobalStyles?: boolean; autoScrollerOptions?: PartialAutoScrollerOptions; + scale?: number; } interface State { @@ -100,8 +101,12 @@ export default class IframeBoard extends Component { key={key} style={{ width: 282, // Magic number based on size of board - height: '100vh', + height: this.props.scale ? `${100 / this.props.scale}vh` : '100vh', border: 0, + transform: this.props.scale + ? `scale(${this.props.scale})` + : undefined, + transformOrigin: this.props.scale ? 'top' : undefined, }} > Date: Tue, 5 Mar 2024 14:42:31 +0100 Subject: [PATCH 06/16] refactor: remove unnecessary changes --- .../dimension-marshal/get-initial-publish.ts | 2 +- src/state/get-frame.ts | 1 - .../move-in-direction/move-cross-axis/index.ts | 2 +- .../move-to-next-place/index.ts | 4 ++-- src/types.ts | 1 - .../sensors/util/offset-point.ts | 6 ------ src/view/window/get-viewport.ts | 16 ++-------------- 7 files changed, 6 insertions(+), 26 deletions(-) diff --git a/src/state/dimension-marshal/get-initial-publish.ts b/src/state/dimension-marshal/get-initial-publish.ts index 048bff8d9..d4b922bb2 100644 --- a/src/state/dimension-marshal/get-initial-publish.ts +++ b/src/state/dimension-marshal/get-initial-publish.ts @@ -31,7 +31,7 @@ export default ({ }: Args): StartPublishingResult => { const timingKey = 'Initial collection from DOM'; timings.start(timingKey); - const viewport: Viewport = getViewport(critical); + const viewport: Viewport = getViewport(); const windowScroll: Position = viewport.scroll.current; const home: DroppableDescriptor = critical.droppable; diff --git a/src/state/get-frame.ts b/src/state/get-frame.ts index d36b6ecdf..525ab5db3 100644 --- a/src/state/get-frame.ts +++ b/src/state/get-frame.ts @@ -5,6 +5,5 @@ import type { DroppableDimension, Scrollable } from '../types'; export default (droppable: DroppableDimension): Scrollable => { const frame: Scrollable | null = droppable.frame; invariant(frame, 'Expected Droppable to have a frame'); - return frame; }; diff --git a/src/state/move-in-direction/move-cross-axis/index.ts b/src/state/move-in-direction/move-cross-axis/index.ts index f15ff1427..28abdeb43 100644 --- a/src/state/move-in-direction/move-cross-axis/index.ts +++ b/src/state/move-in-direction/move-cross-axis/index.ts @@ -1,4 +1,4 @@ -import { type Position } from 'css-box-model'; +import type { Position } from 'css-box-model'; import type { PublicResult } from '../move-in-direction-types'; import type { DroppableDimension, diff --git a/src/state/move-in-direction/move-to-next-place/index.ts b/src/state/move-in-direction/move-to-next-place/index.ts index 707dda941..dd3f7e6c2 100644 --- a/src/state/move-in-direction/move-to-next-place/index.ts +++ b/src/state/move-in-direction/move-to-next-place/index.ts @@ -15,7 +15,7 @@ import isHomeOf from '../../droppable/is-home-of'; import getPageBorderBoxCenter from '../../get-center-from-impact/get-page-border-box-center'; import speculativelyIncrease from '../../update-displacement-visibility/speculatively-increase'; import getClientFromPageBorderBoxCenter from '../../get-center-from-impact/get-client-border-box-center/get-client-from-page-border-box-center'; -import { add, subtract } from '../../position'; +import { subtract } from '../../position'; import isTotallyVisibleInNewLocation from './is-totally-visible-in-new-location'; interface Args { @@ -103,7 +103,6 @@ export default ({ draggable, viewport, }); - return { clientSelection, impact, @@ -123,6 +122,7 @@ export default ({ draggables, maxScrollChange: distance, }); + return { clientSelection: previousClientSelection, impact: cautious, diff --git a/src/types.ts b/src/types.ts index 3ee3b4a6a..bfbb1d8a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -309,7 +309,6 @@ export interface Viewport { // live updates with the latest values frame: Rect; scroll: ScrollDetails; - offset: Rect; } export interface LiftEffect { diff --git a/src/view/use-sensor-marshal/sensors/util/offset-point.ts b/src/view/use-sensor-marshal/sensors/util/offset-point.ts index 941fead0c..c8fb66f75 100644 --- a/src/view/use-sensor-marshal/sensors/util/offset-point.ts +++ b/src/view/use-sensor-marshal/sensors/util/offset-point.ts @@ -6,10 +6,6 @@ export default function offsetPoint( y: number, win: Window, ): Position { - // const { clientX, clientY } = event; - - // const win = event.currentTarget as Window; - let offsetX = 0; let offsetY = 0; @@ -35,8 +31,6 @@ export default function offsetPoint( y: transformedY + offsetY, }; - console.log(transformedX, transformedY); - return point; } } diff --git a/src/view/window/get-viewport.ts b/src/view/window/get-viewport.ts index 1c98c7f3a..0b7d8f6a7 100644 --- a/src/view/window/get-viewport.ts +++ b/src/view/window/get-viewport.ts @@ -1,16 +1,12 @@ import { getRect } from 'css-box-model'; import type { Rect, Position } from 'css-box-model'; -import type { Critical, Viewport } from '../../types'; +import type { Viewport } from '../../types'; import { origin } from '../../state/position'; import getWindowScroll from './get-window-scroll'; import getMaxWindowScroll from './get-max-window-scroll'; import getDocumentElement from '../get-document-element'; -import getIframeOffset from '../iframe/get-iframe-offset'; -import querySelectorAllIframe from '../iframe/query-selector-all-iframe'; -import { prefix } from '../data-attributes'; -import applyOffset from '../iframe/apply-offset'; -export default (critical?: Critical): Viewport => { +export default (): Viewport => { const scroll: Position = getWindowScroll(); const maxScroll: Position = getMaxWindowScroll(); @@ -36,13 +32,6 @@ export default (critical?: Critical): Viewport => { bottom, }); - const droppables = querySelectorAllIframe( - `[${prefix}-droppable-id="${critical?.droppable.id}"]`, - ); - - const offset = getIframeOffset(droppables[0]); - const offsetFrame = applyOffset(frame, offset); - const viewport: Viewport = { frame, scroll: { @@ -54,7 +43,6 @@ export default (critical?: Critical): Viewport => { displacement: origin, }, }, - offset: offsetFrame, }; return viewport; From 35f428e50c253625245079f43795bdcd686b5fe4 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Tue, 5 Mar 2024 18:47:10 +0100 Subject: [PATCH 07/16] docs: show host in iframe stories --- stories/src/board/iframe-board.tsx | 73 +++++++++++++++++------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/stories/src/board/iframe-board.tsx b/stories/src/board/iframe-board.tsx index 136e71bc3..f82def083 100644 --- a/stories/src/board/iframe-board.tsx +++ b/stories/src/board/iframe-board.tsx @@ -96,38 +96,49 @@ export default class IframeBoard extends Component { const columns: QuoteMap = this.state.columns; const ordered: string[] = this.state.ordered; - const board = ordered.map((key: string, index: number) => ( - - { + const El = index > 0 ? AutoFrame : 'div'; + return ( + 0 + ? `${100 / this.props.scale}vh` + : '100vh', + border: 0, + transform: + this.props.scale && index > 0 + ? `scale(${this.props.scale})` + : undefined, + transformOrigin: this.props.scale && index > 0 ? 'top' : undefined, + }} > - {(provided: DroppableProvided) => ( - -
- - {key} (iframe {index}) - -
- - {provided.placeholder} -
- )} -
-
- )); + + {(provided: DroppableProvided) => ( + +
+ + {key} ({index === 0 ? 'host' : `iframe ${index}`}) + +
+ + {provided.placeholder} +
+ )} +
+ + ); + }); return ( From a29a7604abc4d0d4b1c29b1e725a27709046a98d Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Thu, 7 Mar 2024 19:10:36 +0100 Subject: [PATCH 08/16] feat: don't warn about nested scroll containers --- .../use-droppable-publisher/use-droppable-publisher.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/view/use-droppable-publisher/use-droppable-publisher.ts b/src/view/use-droppable-publisher/use-droppable-publisher.ts index 1c3522c97..ee91a158e 100644 --- a/src/view/use-droppable-publisher/use-droppable-publisher.ts +++ b/src/view/use-droppable-publisher/use-droppable-publisher.ts @@ -4,7 +4,7 @@ import rafSchedule from 'raf-schd'; import { useMemo, useCallback } from 'use-memo-one'; import memoizeOne from 'memoize-one'; import { invariant } from '../../invariant'; -import checkForNestedScrollContainers from './check-for-nested-scroll-container'; +// import checkForNestedScrollContainers from './check-for-nested-scroll-container'; import * as dataAttr from '../data-attributes'; import { origin } from '../../state/position'; import getScroll from './get-scroll'; @@ -169,9 +169,9 @@ export default function useDroppablePublisher(args: Props) { getListenerOptions(dragging.scrollOptions), ); // print a debug warning if using an unsupported nested scroll container setup - if (process.env.NODE_ENV !== 'production') { - checkForNestedScrollContainers(scrollable); - } + // if (process.env.NODE_ENV !== 'production') { + // checkForNestedScrollContainers(scrollable); + // } } return dimension; From ce25b3048dcb87a42b875bdf45c11803429cd8af Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Tue, 12 Mar 2024 22:19:41 +0100 Subject: [PATCH 09/16] refactor: ensure Safari binds events to iframes --- src/view/event-bindings/bind-events.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/view/event-bindings/bind-events.ts b/src/view/event-bindings/bind-events.ts index c72cdaab7..08df2a42a 100644 --- a/src/view/event-bindings/bind-events.ts +++ b/src/view/event-bindings/bind-events.ts @@ -1,4 +1,3 @@ -import { IframeHTMLAttributes } from 'react'; import { querySelectorAll } from '../../query-selector-all'; import type { AnyEventBinding, @@ -18,6 +17,26 @@ function getOptions( }; } +let loaded = false; + +function bindEvent(win: Window, binding: EventBinding, options: EventOptions) { + let timer: number | undefined; + + if (!loaded) { + // Some browsers require us to defer binding events, i.e. Safari + timer = setInterval(() => { + if ((win as Window).document.readyState === 'complete') { + win.addEventListener(binding.eventName, binding.fn, options); + loaded = true; + } + }, 100); + } else { + win.addEventListener(binding.eventName, binding.fn, options); + } + + return timer; +} + export default function bindEvents( el: HTMLElement | Window, bindings: AnyEventBinding[], @@ -37,9 +56,10 @@ export default function bindEvents( const options = getOptions(sharedOptions, binding.options); - win.addEventListener(binding.eventName, binding.fn, options); + const timer = bindEvent(win as Window, binding, options); return function unbind() { + clearInterval(timer); win.removeEventListener(binding.eventName, binding.fn, options); }; }); From dcf051dd2263b1ca49180baba9e86cbde8d80ba3 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Thu, 21 Mar 2024 19:27:03 +0100 Subject: [PATCH 10/16] feat: auto-scroll iframes --- .../fluid-scroller/get-iframe-scroll.ts | 76 +++++++++++++++++++ .../auto-scroller/fluid-scroller/index.ts | 2 +- .../auto-scroller/fluid-scroller/scroll.ts | 17 ++++- src/view/window/scroll-window.ts | 6 +- 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts diff --git a/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts b/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts new file mode 100644 index 000000000..7fd781019 --- /dev/null +++ b/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts @@ -0,0 +1,76 @@ +import { BoxModel, getBox } from 'css-box-model'; +import { DraggableDimension, DraggingState } from '../../../types'; +import querySelectorAllIframe from '../../../view/iframe/query-selector-all-iframe'; +import getScroll from './get-scroll'; +import { AutoScrollerOptions } from './auto-scroller-options-types'; +import { Transform, getTransform } from '../../../view/transform'; + +const resetToOrigin = (box: BoxModel, transform: Transform | null) => { + const { scaleX = 1, scaleY = 1 } = transform?.matrix || {}; + + const width = box.marginBox.width / scaleX; + const height = box.marginBox.height / scaleY; + + return { + width, + height, + top: 0, + left: 0, + right: width, + bottom: height, + center: { + x: width / 2, + y: height / 2, + }, + x: 0, + y: 0, + }; +}; + +/** + * Get the scroll for a draggable inside an iframe + * + * - Since iframes are not fully managed by the state, we have to access the elements directly. + * - This will not work with multiple draggable contexts + */ +export default ({ + draggable, + dragStartTime, + getAutoScrollerOptions, + shouldUseTimeDampening, + state, +}: { + state: DraggingState; + draggable: DraggableDimension; + dragStartTime: number; + shouldUseTimeDampening: boolean; + getAutoScrollerOptions: () => AutoScrollerOptions; +}) => { + const el = querySelectorAllIframe( + `[data-rfd-draggable-id="${state.critical.draggable.id}"]`, + )[0]; + + const win = el?.ownerDocument.defaultView || window; + + const isInIframe = win !== window; + + if (isInIframe) { + const iframe = win.frameElement as HTMLIFrameElement; + const viewportBox = getBox(iframe); + const box = getBox(el); + const transform = getTransform(iframe); + + const change = getScroll({ + dragStartTime, + container: resetToOrigin(viewportBox, transform), // Reset to origin because we don't care about position of the iframe + subject: draggable.client.marginBox, + center: box.borderBox.center, + shouldUseTimeDampening, + getAutoScrollerOptions, + }); + + return { change, window: win }; + } + + return null; +}; diff --git a/src/state/auto-scroller/fluid-scroller/index.ts b/src/state/auto-scroller/fluid-scroller/index.ts index e6c0d3da4..2a8b2fef9 100644 --- a/src/state/auto-scroller/fluid-scroller/index.ts +++ b/src/state/auto-scroller/fluid-scroller/index.ts @@ -8,7 +8,7 @@ import { AutoScrollerOptions } from './auto-scroller-options-types'; import { defaultAutoScrollerOptions } from './config'; export interface PublicArgs { - scrollWindow: (change: Position) => void; + scrollWindow: (change: Position, win?: Window) => void; scrollDroppable: (id: DroppableId, change: Position) => void; getAutoScrollerOptions?: () => AutoScrollerOptions; } diff --git a/src/state/auto-scroller/fluid-scroller/scroll.ts b/src/state/auto-scroller/fluid-scroller/scroll.ts index 9509e1f4a..042a6c6c5 100644 --- a/src/state/auto-scroller/fluid-scroller/scroll.ts +++ b/src/state/auto-scroller/fluid-scroller/scroll.ts @@ -11,12 +11,13 @@ import whatIsDraggedOver from '../../droppable/what-is-dragged-over'; import getWindowScrollChange from './get-window-scroll-change'; import getDroppableScrollChange from './get-droppable-scroll-change'; import { AutoScrollerOptions } from './auto-scroller-options-types'; +import getIframeScroll from './get-iframe-scroll'; interface Args { state: DraggingState; dragStartTime: number; shouldUseTimeDampening: boolean; - scrollWindow: (scroll: Position) => void; + scrollWindow: (scroll: Position, win?: Window) => void; scrollDroppable: (id: DroppableId, scroll: Position) => void; getAutoScrollerOptions: () => AutoScrollerOptions; } @@ -51,6 +52,20 @@ export default ({ } } + const iframeScroll = getIframeScroll({ + state, + dragStartTime, + shouldUseTimeDampening, + getAutoScrollerOptions, + draggable, + }); + + if (iframeScroll?.change) { + scrollWindow(iframeScroll.change, iframeScroll.window); + + return; + } + const droppable: DroppableDimension | null = getBestScrollableDroppable({ center, destination: whatIsDraggedOver(state.impact), diff --git a/src/view/window/scroll-window.ts b/src/view/window/scroll-window.ts index 1e96e18b0..68f48bac5 100644 --- a/src/view/window/scroll-window.ts +++ b/src/view/window/scroll-window.ts @@ -1,6 +1,6 @@ import type { Position } from 'css-box-model'; -// Not guarenteed to scroll by the entire amount -export default (change: Position): void => { - window.scrollBy(change.x, change.y); +// Not guaranteed to scroll by the entire amount +export default (change: Position, win: Window = window): void => { + win.scrollBy(change.x, change.y); }; From c7c88e8d9d564b791ed398c032bfabb889419a97 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Wed, 27 Mar 2024 14:09:34 +0100 Subject: [PATCH 11/16] feat: add disableSecondaryAnimation API to draggables This allows the user to explicitly disable displacement animations. For example, complex list objects can cause iOS GPU memory crashes in Safari when combined with iframes and CSS transforms, but disabling displacement animations fixes it. --- src/view/draggable/connected-draggable.ts | 37 ++++++++++++++++++----- src/view/draggable/draggable-types.ts | 1 + 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/view/draggable/connected-draggable.ts b/src/view/draggable/connected-draggable.ts index b17bb3347..4532e00ed 100644 --- a/src/view/draggable/connected-draggable.ts +++ b/src/view/draggable/connected-draggable.ts @@ -254,9 +254,10 @@ function getSecondarySelector(): TrySelect { // otherwise we will return null to get the default props const getFallback = ( combineTargetFor?: DraggableId | null, + disableSecondaryAnimation?: boolean, ): MapProps | null => { return combineTargetFor - ? getMemoizedProps(origin, combineTargetFor, true) + ? getMemoizedProps(origin, combineTargetFor, !disableSecondaryAnimation) : null; }; @@ -267,6 +268,7 @@ function getSecondarySelector(): TrySelect { afterCritical: LiftEffect, dimension?: DraggableDimension, sourceDroppable?: DroppableDimension | null, + disableSecondaryAnimation?: boolean, ): MapProps | null => { const visualDisplacement: Displacement | null = impact.displaced.visible[ownId]; @@ -280,7 +282,7 @@ function getSecondarySelector(): TrySelect { if (!visualDisplacement) { if (!isAfterCriticalInVirtualList) { - return getFallback(combineTargetFor); + return getFallback(combineTargetFor, disableSecondaryAnimation); } // After critical but not visibly displaced in a virtual list @@ -300,7 +302,7 @@ function getSecondarySelector(): TrySelect { return getMemoizedProps( offset, combineTargetFor, - true, + !disableSecondaryAnimation, dimension, sourceDroppable, ); @@ -318,7 +320,7 @@ function getSecondarySelector(): TrySelect { return getMemoizedProps( offset, combineTargetFor, - visualDisplacement.shouldAnimate, + disableSecondaryAnimation ? false : visualDisplacement.shouldAnimate, dimension, sourceDroppable, ); @@ -348,6 +350,7 @@ function getSecondarySelector(): TrySelect { state.afterCritical, dimension, sourceDroppable, + ownProps.disableSecondaryAnimation, ); } @@ -371,6 +374,7 @@ function getSecondarySelector(): TrySelect { completed.afterCritical, dimension, sourceDroppable, + ownProps.disableSecondaryAnimation, ); } @@ -387,10 +391,27 @@ export const makeMapStateToProps = (): Selector => { const draggingSelector: TrySelect = getDraggableSelector(); const secondarySelector: TrySelect = getSecondarySelector(); - const selector = (state: State, ownProps: OwnProps): MapProps => - draggingSelector(state, ownProps) || - secondarySelector(state, ownProps) || - atRest; + const selector = (state: State, ownProps: OwnProps): MapProps => { + // Modify atRest based on props + const atRestLocal = + atRest.mapped.type === 'DRAGGING' + ? atRest + : { + ...atRest, + mapped: { + ...atRest.mapped, + shouldAnimateDisplacement: ownProps.disableSecondaryAnimation + ? false + : atRest.mapped.shouldAnimateDisplacement, + }, + }; + + return ( + draggingSelector(state, ownProps) || + secondarySelector(state, ownProps) || + atRestLocal + ); + }; return selector; }; diff --git a/src/view/draggable/draggable-types.ts b/src/view/draggable/draggable-types.ts index ce8190f30..07c440362 100644 --- a/src/view/draggable/draggable-types.ts +++ b/src/view/draggable/draggable-types.ts @@ -168,6 +168,7 @@ export interface DraggableProps { isDragDisabled?: boolean; disableInteractiveElementBlocking?: boolean; shouldRespectForcePress?: boolean; + disableSecondaryAnimation?: boolean; } export interface PrivateOwnProps extends DraggableProps { From 4b9daa14243c8ce929c4e61b5119791661a2f82c Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Wed, 3 Apr 2024 12:50:36 +0200 Subject: [PATCH 12/16] fix: don't query iframes that aren't for dnd --- src/view/event-bindings/bind-events.ts | 2 +- src/view/iframe/query-selector-all-iframe.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/view/event-bindings/bind-events.ts b/src/view/event-bindings/bind-events.ts index 08df2a42a..ebb164a04 100644 --- a/src/view/event-bindings/bind-events.ts +++ b/src/view/event-bindings/bind-events.ts @@ -46,7 +46,7 @@ export default function bindEvents( (binding): UnbindFn[] => { const iframes: HTMLIFrameElement[] = querySelectorAll( window.document, - 'iframe', + '[data-rfd-iframe]', ) as HTMLIFrameElement[]; const windows = [el, ...iframes.map((iframe) => iframe.contentWindow)]; diff --git a/src/view/iframe/query-selector-all-iframe.ts b/src/view/iframe/query-selector-all-iframe.ts index a94b048fc..461e23649 100644 --- a/src/view/iframe/query-selector-all-iframe.ts +++ b/src/view/iframe/query-selector-all-iframe.ts @@ -1,13 +1,15 @@ +import { querySelectorAll } from '../../query-selector-all'; + /** * querySelectorAllIframe * * An proxy of querySelectorAll that also queries all iframes */ - -import { querySelectorAll } from '../../query-selector-all'; - export default function querySelectorAllIframe(selector: string) { - const iframes = querySelectorAll(document, 'iframe') as HTMLIFrameElement[]; + const iframes = querySelectorAll( + document, + '[data-rfd-iframe]', + ) as HTMLIFrameElement[]; const iframePossible = iframes.reduce( (acc, iframe) => [ From a2766ded01b9640e772cd0fccb2ad2be0e66dbb9 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Wed, 3 Apr 2024 14:11:59 +0200 Subject: [PATCH 13/16] perf: cache expensive query selections --- package.json | 3 ++- pnpm-lock.yaml | 12 ++++++----- src/view/get-elements/find-drag-handle.ts | 14 +++++++++++++ src/view/iframe/query-selector-all-iframe.ts | 22 ++++++++++++++++---- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index a70df75f8..35936550d 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "dependencies": { "@babel/runtime": "^7.23.2", "css-box-model": "^1.2.1", + "lru-cache": "^10.2.0", "memoize-one": "^6.0.0", "raf-schd": "^4.0.3", "react-redux": "^8.1.3", @@ -103,8 +104,8 @@ "@emotion/eslint-plugin": "11.11.0", "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", - "@measured/auto-frame-component": "0.1.0-canary.4686711", "@jest/environment": "29.7.0", + "@measured/auto-frame-component": "0.1.0-canary.4686711", "@release-it/conventional-changelog": "8.0.1", "@rollup/plugin-babel": "6.0.4", "@rollup/plugin-commonjs": "25.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a40c4a5e2..0f9dc1732 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: css-box-model: specifier: ^1.2.1 version: 1.2.1 + lru-cache: + specifier: ^10.2.0 + version: 10.2.0 memoize-one: specifier: ^6.0.0 version: 6.0.0 @@ -11544,7 +11547,7 @@ packages: resolution: {integrity: sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==} engines: {node: ^16.14.0 || >=18.0.0} dependencies: - lru-cache: 10.0.1 + lru-cache: 10.2.0 dev: true /html-encoding-sniffer@3.0.0: @@ -13921,10 +13924,9 @@ packages: highlight.js: 10.7.3 dev: true - /lru-cache@10.0.1: - resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==} + /lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} engines: {node: 14 || >=16.14} - dev: true /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -15351,7 +15353,7 @@ packages: resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} engines: {node: '>=16 || 14 >=14.17'} dependencies: - lru-cache: 10.0.1 + lru-cache: 10.2.0 minipass: 7.0.2 dev: true diff --git a/src/view/get-elements/find-drag-handle.ts b/src/view/get-elements/find-drag-handle.ts index 3aebb10c1..12d5bde88 100644 --- a/src/view/get-elements/find-drag-handle.ts +++ b/src/view/get-elements/find-drag-handle.ts @@ -1,9 +1,15 @@ +import { LRUCache } from 'lru-cache'; import type { DraggableId, ContextId } from '../../types'; import { dragHandle as dragHandleAttr } from '../data-attributes'; import { warning } from '../../dev-warning'; import isHtmlElement from '../is-type-of-element/is-html-element'; import querySelectorAllIframe from '../iframe/query-selector-all-iframe'; +const dragHandleCache = new LRUCache({ + max: 5000, + ttl: 1000, +}); + export default function findDragHandle( contextId: ContextId, draggableId: DraggableId, @@ -11,6 +17,12 @@ export default function findDragHandle( // cannot create a selector with the draggable id as it might not be a valid attribute selector const selector = `[${dragHandleAttr.contextId}="${contextId}"]`; + const cachedHandle = dragHandleCache.get(selector); + + if (cachedHandle) { + return cachedHandle; + } + const possible = querySelectorAllIframe(selector); if (!possible.length) { @@ -36,5 +48,7 @@ export default function findDragHandle( return null; } + dragHandleCache.set(selector, handle); + return handle; } diff --git a/src/view/iframe/query-selector-all-iframe.ts b/src/view/iframe/query-selector-all-iframe.ts index 461e23649..90202105f 100644 --- a/src/view/iframe/query-selector-all-iframe.ts +++ b/src/view/iframe/query-selector-all-iframe.ts @@ -1,15 +1,29 @@ +import { LRUCache } from 'lru-cache'; import { querySelectorAll } from '../../query-selector-all'; +const iframeCache = new LRUCache({ + max: 1, + ttl: 1000, +}); + /** * querySelectorAllIframe * * An proxy of querySelectorAll that also queries all iframes */ export default function querySelectorAllIframe(selector: string) { - const iframes = querySelectorAll( - document, - '[data-rfd-iframe]', - ) as HTMLIFrameElement[]; + let iframes = iframeCache.get('iframes'); + + if (!iframes) { + iframes = querySelectorAll(document, 'iframe') as HTMLIFrameElement[]; + + // Quicker than running the [data-rbd-frame] query + iframes = iframes.filter((iframe) => + iframe.hasAttribute('data-rfd-iframe'), + ); + + iframeCache.set('iframes', iframes); + } const iframePossible = iframes.reduce( (acc, iframe) => [ From 9296074431dce92cd3599ed91a46e1d1c28d5a26 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Wed, 3 Apr 2024 14:44:34 +0200 Subject: [PATCH 14/16] fix: don't query iframe document if contentWindow doesn't exist --- src/view/iframe/query-selector-all-iframe.ts | 4 +++- src/view/use-style-marshal/use-style-marshal.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/view/iframe/query-selector-all-iframe.ts b/src/view/iframe/query-selector-all-iframe.ts index 90202105f..56e7f75af 100644 --- a/src/view/iframe/query-selector-all-iframe.ts +++ b/src/view/iframe/query-selector-all-iframe.ts @@ -28,7 +28,9 @@ export default function querySelectorAllIframe(selector: string) { const iframePossible = iframes.reduce( (acc, iframe) => [ ...acc, - ...querySelectorAll(iframe.contentWindow!.document, selector), + ...(iframe.contentWindow?.document + ? querySelectorAll(iframe.contentWindow.document, selector) + : []), ], [], ); diff --git a/src/view/use-style-marshal/use-style-marshal.ts b/src/view/use-style-marshal/use-style-marshal.ts index 672773bc3..38e7b4cfc 100644 --- a/src/view/use-style-marshal/use-style-marshal.ts +++ b/src/view/use-style-marshal/use-style-marshal.ts @@ -65,7 +65,9 @@ export default function useStyleMarshal(contextId: ContextId, nonce?: string) { getHead(document), ...( querySelectorAll(document, `[${prefix}-iframe]`) as HTMLIFrameElement[] - ).map((iframe) => getHead(iframe.contentWindow!.document)), + ) + .filter((iframe) => iframe.contentWindow?.document) + .map((iframe) => getHead(iframe.contentWindow!.document)), ]; // Create initial style elements From eda7e8b9e5a29475d91eebb446e03a8dce62bdb7 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Sat, 6 Apr 2024 18:19:18 +0200 Subject: [PATCH 15/16] feat: select deepest candidate when using nested droppables --- src/state/droppable/get-droppable.ts | 3 + src/state/get-droppable-over.ts | 58 ++++++++++++++++++- src/types.ts | 1 + .../use-droppable-publisher/get-dimension.ts | 35 +++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/state/droppable/get-droppable.ts b/src/state/droppable/get-droppable.ts index 1c72f93a2..8847ba271 100644 --- a/src/state/droppable/get-droppable.ts +++ b/src/state/droppable/get-droppable.ts @@ -32,6 +32,7 @@ interface Args { page: BoxModel; closest?: Closest | null; transform: Transform | null; + parents: DroppableDescriptor[]; } export default ({ @@ -44,6 +45,7 @@ export default ({ page, closest, transform, + parents, }: Args): DroppableDimension => { const frame: Scrollable | null = (() => { if (!closest) { @@ -98,6 +100,7 @@ export default ({ frame, subject, transform, + parents, }; return dimension; diff --git a/src/state/get-droppable-over.ts b/src/state/get-droppable-over.ts index bfc9532ab..42a6eacca 100644 --- a/src/state/get-droppable-over.ts +++ b/src/state/get-droppable-over.ts @@ -75,6 +75,53 @@ function getFurthestAway({ return sorted[0] ? sorted[0].id : null; } +/** + * normalizeFamilies + * + * Groups all items that share a common root `parent`, and selects the deepest item + * in that group that contains the center point of the dragged item to represent + * the "family". + */ +function normalizeFamilies( + pageBorderBox: Rect, + candidates: DroppableDimension[], +) { + const families = candidates.reduce>( + (acc, candidate) => { + const familyName = candidate.parents[0]?.id || candidate.descriptor.id; + const family = acc[familyName] || []; + + const generation = candidate.parents.length; + + family[generation] = [...(family[generation] || []), candidate]; + + return { + ...acc, + [familyName]: family, + }; + }, + {}, + ); + + return Object.keys(families).map((familyName) => { + const family = families[familyName].flat(); + + const reversedFamily = [...family].reverse(); + + // Get first member of family that contains the draggable + const chosenMember = reversedFamily.find((member) => { + return ( + pageBorderBox.center.x < member.page.borderBox.right && + pageBorderBox.center.x > member.page.borderBox.left && + pageBorderBox.center.y > member.page.borderBox.top && + pageBorderBox.center.y < member.page.borderBox.bottom + ); + }); + + return chosenMember || family[0]; + }); +} + export default function getDroppableOver({ pageBorderBox, draggable, @@ -146,12 +193,19 @@ export default function getDroppableOver({ return candidates[0].descriptor.id; } - // Multiple options returned + // Select the best candidate from each group that share a common root ancestor + const normalizedCandidates = normalizeFamilies(pageBorderBox, candidates); + + // All candidates were in the same family + if (normalizedCandidates.length === 1) { + return normalizedCandidates[0].descriptor.id; + } + // Should only occur with really large items // Going to use fallback: distance from home return getFurthestAway({ pageBorderBox, draggable, - candidates, + candidates: normalizedCandidates, }); } diff --git a/src/types.ts b/src/types.ts index bfbb1d8a8..2028c563f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,6 +154,7 @@ export interface DroppableDimension { // what is visible through the frame subject: DroppableSubject; transform: Transform | null; + parents: DroppableDescriptor[]; } export interface DraggableLocation { droppableId: DroppableId; diff --git a/src/view/use-droppable-publisher/get-dimension.ts b/src/view/use-droppable-publisher/get-dimension.ts index 26d3449c0..09c0c59da 100644 --- a/src/view/use-droppable-publisher/get-dimension.ts +++ b/src/view/use-droppable-publisher/get-dimension.ts @@ -14,6 +14,7 @@ import getIframeOffset from '../iframe/get-iframe-offset'; import { applyOffsetBox } from '../iframe/apply-offset'; import { Transform, applyTransformBox, getTransform } from '../transform'; import { Offset } from '../iframe/offset-types'; +import { prefix } from '../data-attributes'; const getClient = ( targetRef: HTMLElement, @@ -96,6 +97,37 @@ interface Args { transform: Transform | null; } +const getParents = (ref: HTMLElement) => { + const contextId = ref.getAttribute(`${prefix}-droppable-context-id`); + + const parentDescriptors: DroppableDescriptor[] = []; + + if (!contextId) return []; + + let currentEl: HTMLElement | null | undefined = ref; + + while (currentEl) { + currentEl = currentEl.parentElement?.closest( + `[${prefix}-droppable-context-id="${contextId}"]`, + ); + + const id = currentEl?.getAttribute(`${prefix}-droppable-id`); + + if (id) { + parentDescriptors.push({ + id, + mode: 'standard', + type: 'DEFAULT', + }); + } + } + + // Parents need reversing + parentDescriptors.reverse(); + + return parentDescriptors; +}; + export default ({ ref, descriptor, @@ -132,6 +164,8 @@ export default ({ }; })(); + const parents = getParents(ref); + const dimension: DroppableDimension = getDroppableDimension({ descriptor, isEnabled: !isDropDisabled, @@ -142,6 +176,7 @@ export default ({ page, closest, transform, + parents, }); return dimension; From 4cba1d137fe9b805575150a10c2afd0ef2ab4bd8 Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Thu, 11 Apr 2024 15:35:28 +0100 Subject: [PATCH 16/16] fix: don't crash if entire lib is used in an additional iframe --- .../fluid-scroller/get-iframe-scroll.ts | 2 +- src/view/iframe/get-iframe.ts | 2 +- src/view/transform/index.ts | 6 ++- .../sensors/util/offset-point.ts | 37 ++++++++++--------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts b/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts index 7fd781019..af4616d9d 100644 --- a/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts +++ b/src/state/auto-scroller/fluid-scroller/get-iframe-scroll.ts @@ -52,7 +52,7 @@ export default ({ const win = el?.ownerDocument.defaultView || window; - const isInIframe = win !== window; + const isInIframe = win !== window && win.frameElement; if (isInIframe) { const iframe = win.frameElement as HTMLIFrameElement; diff --git a/src/view/iframe/get-iframe.ts b/src/view/iframe/get-iframe.ts index 361f7cf8e..6f6b9fb29 100644 --- a/src/view/iframe/get-iframe.ts +++ b/src/view/iframe/get-iframe.ts @@ -2,7 +2,7 @@ export default function getIframe(el: HTMLElement) { const refWindow = el.ownerDocument.defaultView; if (refWindow && refWindow.self !== refWindow.parent) { - const iframe = refWindow.frameElement as HTMLIFrameElement; + const iframe = refWindow.frameElement as HTMLIFrameElement | null; return iframe; } diff --git a/src/view/transform/index.ts b/src/view/transform/index.ts index fcbc8e8aa..d5a6e303a 100644 --- a/src/view/transform/index.ts +++ b/src/view/transform/index.ts @@ -86,7 +86,11 @@ const findNearestTransform = (el: HTMLElement): HTMLElement | null => { if (!el.parentElement) { const refWindow = el.ownerDocument.defaultView; - if (refWindow && refWindow.self !== refWindow.parent) { + if ( + refWindow && + refWindow.self !== refWindow.parent && + refWindow.frameElement + ) { const iframe = refWindow.frameElement as HTMLIFrameElement; return findNearestTransform(iframe); diff --git a/src/view/use-sensor-marshal/sensors/util/offset-point.ts b/src/view/use-sensor-marshal/sensors/util/offset-point.ts index c8fb66f75..b268c6b8d 100644 --- a/src/view/use-sensor-marshal/sensors/util/offset-point.ts +++ b/src/view/use-sensor-marshal/sensors/util/offset-point.ts @@ -10,28 +10,31 @@ export default function offsetPoint( let offsetY = 0; if (win.parent !== win.self) { - const iframe = win.frameElement as HTMLIFrameElement; - const rect = iframe.getBoundingClientRect(); + const iframe = win.frameElement as HTMLIFrameElement | null; - const transform = getTransform(iframe); + if (iframe) { + const rect = iframe.getBoundingClientRect(); - offsetX = rect.left; - offsetY = rect.top; + const transform = getTransform(iframe); - if (transform) { - const { x: transformedX, y: transformedY } = applyTransformPoint( - x, - y, - transform.matrix, - transform.origin, - ); + offsetX = rect.left; + offsetY = rect.top; - const point: Position = { - x: transformedX + offsetX, - y: transformedY + offsetY, - }; + if (transform) { + const { x: transformedX, y: transformedY } = applyTransformPoint( + x, + y, + transform.matrix, + transform.origin, + ); - return point; + const point: Position = { + x: transformedX + offsetX, + y: transformedY + offsetY, + }; + + return point; + } } }