From acc0aece3f716dcf9e31032cc17bb1fb0b83ae8e Mon Sep 17 00:00:00 2001 From: markm39 Date: Sat, 26 Sep 2026 16:06:26 -0500 Subject: [PATCH] fix(editor): insert text in local page coordinates --- package.json | 2 +- scripts/overlayTextInsertion.test.mjs | 95 ++++++++++++++++++++++++++ src/components/editor/OverlayLayer.tsx | 12 ++-- src/hooks/useViewportTransform.ts | 29 ++++---- 4 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 scripts/overlayTextInsertion.test.mjs diff --git a/package.json b/package.json index cc4f420..0131cdb 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "android": "expo run:android", "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs", "test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs", - "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs", + "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/reviewPromptService.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs scripts/exportService.test.mjs scripts/overlayTextInsertion.test.mjs", "test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/scripts/overlayTextInsertion.test.mjs b/scripts/overlayTextInsertion.test.mjs new file mode 100644 index 0000000..c4f0e00 --- /dev/null +++ b/scripts/overlayTextInsertion.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; + +// Exercise the real Pressable handler; native locationX/Y are already local +// to the transformed overlay, regardless of the viewport zoom and scroll. +function harness(transform, pageCount = 3) { + const created = []; + const selected = []; + const react = { + createElement: (type, props, ...children) => ({ type, props, children }), + useCallback: (fn) => fn, + useEffect: () => {}, + useRef: (current) => ({ current }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}], + }; + react.default = react; + function load(relative, dependencies = {}) { + const exports = {}; + const source = readFileSync(new URL(relative, import.meta.url), 'utf8'); + vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.React }, + }).outputText, { + exports, + require(name) { + if (name === 'react') return react; + if (name in dependencies) return dependencies[name]; + throw new Error('Unexpected import: ' + name); + }, + }); + return exports; + } + const viewport = load('../src/hooks/useViewportTransform.ts'); + const { OverlayLayer } = load('../src/components/editor/OverlayLayer.tsx', { + 'react-native': { + View: 'View', Pressable: 'Pressable', Keyboard: { dismiss() {} }, + StyleSheet: { absoluteFill: {}, create: (styles) => styles }, + }, + './TextBoxOverlay': { TextBoxOverlay: 'TextBoxOverlay' }, + './ImageInsertOverlay': { ImageInsertOverlay: 'ImageInsertOverlay' }, + '../../hooks/useViewportTransform': viewport, + }); + const tree = OverlayLayer({ + store: { getSnapshot: () => transform }, + pages: Array.from({ length: pageCount }, (_, i) => ({ id: String(i) })), + pageWidth: 820, pageHeight: 1061, activeTool: 'text', activeColor: '#123456', + selection: null, + onCreateTextBoxId: () => 'new-text', + onCreateTextBox: (pageIndex, box) => created.push({ pageIndex, x: box.x, y: box.y }), + onSelectionChange: (value) => selected.push(value), + }); + return { + created, selected, + tap: (x, y) => tree.children[0].props.onPress({ nativeEvent: { locationX: x, locationY: y } }), + }; +} + +for (const transform of [ + { scale: 1, translateX: 0, translateY: 0 }, + { scale: 0.5, translateX: 16, translateY: -500 }, + { scale: 2, translateX: -240, translateY: -1700 }, +]) { + test(`text stays at the tapped page position at zoom ${transform.scale}`, () => { + const h = harness(transform); + h.tap(200, 200); + h.tap(300, 1061 + 400); + h.tap(400, 2 * 1061 + 500); + assert.deepEqual(h.created, [ + { pageIndex: 0, x: 194, y: 188 }, + { pageIndex: 1, x: 294, y: 388 }, + { pageIndex: 2, x: 394, y: 488 }, + ]); + assert.equal(h.selected[2].pageIndex, 2); + assert.equal(h.selected[2].editing, true); + }); +} + +test('page boundaries select the following page and edge offsets stay nonnegative', () => { + const h = harness({ scale: 0.5, translateX: 16, translateY: -500 }); + h.tap(0, 0); + h.tap(820, 1061); + assert.deepEqual(h.created, [{ pageIndex: 0, x: 0, y: 0 }, { pageIndex: 1, x: 814, y: 0 }]); +}); + +test('taps outside the page content or with invalid coordinates do not create text', () => { + const h = harness({ scale: 1, translateX: 0, translateY: 0 }); + for (const [x, y] of [[-1, 200], [821, 200], [200, -1], [200, 3183], [NaN, 20], [20, Infinity]]) h.tap(x, y); + assert.equal(h.created.length, 0); + assert.equal(h.selected.length, 0); + const empty = harness({ scale: 1, translateX: 0, translateY: 0 }, 0); + empty.tap(10, 10); + assert.equal(empty.created.length, 0); +}); diff --git a/src/components/editor/OverlayLayer.tsx b/src/components/editor/OverlayLayer.tsx index 6d36621..d580b6d 100644 --- a/src/components/editor/OverlayLayer.tsx +++ b/src/components/editor/OverlayLayer.tsx @@ -15,10 +15,9 @@ import type { import { TextBoxOverlay } from './TextBoxOverlay'; import { ImageInsertOverlay } from './ImageInsertOverlay'; import type { ViewportTransformStore } from '../../hooks/useViewportTransform'; -import { screenToPageCoord } from '../../hooks/useViewportTransform'; +import { contentToPageCoord } from '../../hooks/useViewportTransform'; import type { SupportedTool } from '../../utils/toolPalette'; -const CONTENT_PADDING = 16; const PAGE_GAP = 0; export interface OverlaySelection { @@ -82,16 +81,15 @@ export function OverlayLayer({ const handleTextToolTap = useCallback( (event: GestureResponderEvent) => { - const transform = transformRef.current; - if (!transform || activeTool !== 'text') return; + if (activeTool !== 'text') return; const { locationX, locationY } = event.nativeEvent; - const coord = screenToPageCoord( - transform, + // Native touch coordinates are already local to the transformed page + // content, inside the canvas padding. Do not invert the viewport again. + const coord = contentToPageCoord( locationX, locationY, pageWidth, pageHeight, - CONTENT_PADDING, PAGE_GAP, pages.length, ); diff --git a/src/hooks/useViewportTransform.ts b/src/hooks/useViewportTransform.ts index 31f16c9..720a093 100644 --- a/src/hooks/useViewportTransform.ts +++ b/src/hooks/useViewportTransform.ts @@ -83,29 +83,24 @@ export interface PageCoord { y: number; } -export function screenToPageCoord( - transform: InfiniteInkViewportTransform, - screenX: number, - screenY: number, +export function contentToPageCoord( + contentX: number, + contentY: number, pageWidth: number, pageHeight: number, - contentPadding: number, pageGap: number, pageCount: number, ): PageCoord | null { - const scale = transform.scale; - if (!Number.isFinite(scale) || scale <= 0) return null; - const contentX = (screenX - transform.translateX) / scale; - const contentY = (screenY - transform.translateY) / scale; - const localX = contentX - contentPadding; - if (localX < 0 || localX > pageWidth) return null; + if ( + !Number.isFinite(contentX) || !Number.isFinite(contentY) || + pageWidth <= 0 || pageHeight <= 0 || pageCount <= 0 || + contentX < 0 || contentX > pageWidth || contentY < 0 + ) return null; const stride = pageHeight + pageGap; - const fromTop = contentY - contentPadding; - if (fromTop < 0) return null; - const pageIndex = Math.min(pageCount - 1, Math.floor(fromTop / stride)); - const offsetWithinPage = fromTop - pageIndex * stride; - if (offsetWithinPage > pageHeight) return null; - return { pageIndex, x: localX, y: offsetWithinPage }; + const pageIndex = Math.floor(contentY / stride); + const offsetWithinPage = contentY - pageIndex * stride; + if (pageIndex >= pageCount || offsetWithinPage >= pageHeight) return null; + return { pageIndex, x: contentX, y: offsetWithinPage }; } export function useViewportSubscribe(