From 7d01fb00a6cd7d09d52bfca1b364c139beb69bce Mon Sep 17 00:00:00 2001 From: Calum Knott Date: Sun, 1 Nov 2026 19:41:43 +0000 Subject: [PATCH] Add opt-in disablePanScroll embed scrolling Embedded boards capture wheel for pan/pinch-zoom with no way out short of readonly. disablePanScroll opts in to passing plain wheel events through to the page, while Ctrl/Meta+wheel still zooms the board (the browser would page-zoom without the capture). Off by default: existing behavior is byte-identical. Forwarded through live-switchably; pointer tools including hand drag-pan are unaffected either way. --- packages/core/src/editor.js | 10 +++++++++- packages/core/test/editor.test.js | 20 ++++++++++++++++++++ packages/core/types/index.d.ts | 4 ++++ packages/react/src/index.js | 6 ++++++ packages/react/types/index.d.ts | 2 ++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/core/src/editor.js b/packages/core/src/editor.js index 236e459..3407952 100644 --- a/packages/core/src/editor.js +++ b/packages/core/src/editor.js @@ -35,12 +35,17 @@ const bendMidpoint = (pr) => { } export class Editor { - constructor({ container, store, theme = 'light', grid = 'lines', readonly = false, camera, styles, geoKind } = {}) { + constructor({ container, store, theme = 'light', grid = 'lines', readonly = false, camera, styles, geoKind, disablePanScroll = false } = {}) { this.container = container this.store = store || new Store() this.theme = themeOf(theme) this.grid = GRID_IDS.includes(grid) ? grid : 'lines' this.readonly = !!readonly + // Opt-in embed mode: plain wheel events pass through to the page instead + // of panning, while pinch-zoom (Ctrl/Meta+wheel) zooms the board instead + // of the page. Pointer tools (including the hand tool's drag-pan) are + // unaffected either way. + this.disablePanScroll = !!disablePanScroll this.camera = camera || { x: 0, y: 0, z: 1 } this.styles = { ...DEFAULT_STYLES, ...(styles || {}) } this.geoKind = geoKind || 'rectangle' @@ -1463,6 +1468,9 @@ export class Editor { _wheel(e) { if (this.readonly) return + // Embed mode: plain scroll belongs to the page — but pinch-zoom still + // zooms the board, since the browser would page-zoom without the capture. + if (this.disablePanScroll && !(e.ctrlKey || e.metaKey)) return e.preventDefault() const s = this._evPoint(e) if (e.ctrlKey || e.metaKey) { diff --git a/packages/core/test/editor.test.js b/packages/core/test/editor.test.js index 25280ff..6ce62f2 100644 --- a/packages/core/test/editor.test.js +++ b/packages/core/test/editor.test.js @@ -52,6 +52,26 @@ describe('setup', () => { expect(TOOLS).toContain('draw') }) + it('disablePanScroll passes plain scroll through but keeps pinch-zoom', () => { + const embed = new Editor({ container: document.createElement('div'), disablePanScroll: true }) + const before = { ...embed.camera } + let prevented = false + embed._wheel({ deltaX: 0, deltaY: 100, ctrlKey: false, metaKey: false, preventDefault: () => { prevented = true } }) + expect(embed.camera).toEqual(before) + expect(prevented).toBe(false) + // ...but pinch-zoom still captures and zooms + const zBefore = embed.camera.z + embed._wheel({ clientX: 50, clientY: 50, deltaX: 0, deltaY: -100, ctrlKey: true, metaKey: false, preventDefault: () => { prevented = true } }) + expect(embed.camera.z).toBeGreaterThan(zBefore) + expect(prevented).toBe(true) + embed.destroy() + // default captures: pans and prevents default + const before2 = { ...editor.camera } + editor._wheel({ clientX: 50, clientY: 50, deltaX: 10, deltaY: 0, ctrlKey: false, metaKey: false, preventDefault: () => { prevented = true } }) + expect(editor.camera).not.toEqual(before2) + expect(prevented).toBe(true) + }) + it('camera math round-trips', () => { editor.setCamera({ x: 50, y: -20, z: 2 }) const p = editor.screenToPage(100, 100) diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index f283c44..c7454c0 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -230,6 +230,9 @@ export interface EditorOptions { camera?: Camera styles?: Partial geoKind?: GeoId + /** Opt in to embed scrolling: plain wheel passes to the page, Ctrl/Meta+wheel zooms the board. + * Off by default (wheel pans as before); pointer tools keep working either way. */ + disablePanScroll?: boolean } export type EditorEvent = @@ -250,6 +253,7 @@ export class Editor { theme: Theme grid: GridId readonly: boolean + disablePanScroll: boolean camera: Camera styles: Styles geoKind: GeoId diff --git a/packages/react/src/index.js b/packages/react/src/index.js index 4e2f07e..4488d94 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -40,6 +40,7 @@ export const Quickdraw = forwardRef(function Quickdraw(props, ref) { grid = 'lines', readonly = false, hideUi = false, + disablePanScroll = false, themeToggle = true, gridControl = true, watermark = true, @@ -77,6 +78,7 @@ export const Quickdraw = forwardRef(function Quickdraw(props, ref) { theme, grid, readonly, + disablePanScroll, camera, styles, }) @@ -166,6 +168,10 @@ export const Quickdraw = forwardRef(function Quickdraw(props, ref) { uiRef.current?.setHidden(hideUi || readonly) }, [readonly, hideUi]) + useEffect(() => { + if (editorRef.current) editorRef.current.disablePanScroll = !!disablePanScroll + }, [disablePanScroll]) + useEffect(() => { uiRef.current?.setOptions({ themeToggle, gridControl }) }, [themeToggle, gridControl]) diff --git a/packages/react/types/index.d.ts b/packages/react/types/index.d.ts index ae65d23..106b6e4 100644 --- a/packages/react/types/index.d.ts +++ b/packages/react/types/index.d.ts @@ -17,6 +17,8 @@ export interface QuickdrawProps { grid?: GridId /** Lock input (also hides the toolbar). */ readonly?: boolean + /** Opt in to embed scrolling: plain wheel passes to the page, Ctrl/Meta+wheel zooms the board. Live-switchable. */ + disablePanScroll?: boolean /** Hide the stock toolbar (bring your own chrome). */ hideUi?: boolean /** Show the theme switch in the board menu (default true). */