From 88825dcfe483e756b27a0841254b885889d1b400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 11 Sep 2026 19:27:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(contenteditable):=20=ED=88=AC=EC=98=81?= =?UTF-8?q?=20adapter=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=ED=83=90=EC=83=89?= =?UTF-8?q?=20capability=EB=A5=BC=20=EB=B3=B4=EC=A1=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/editing.md | 6 ++++++ .../src/dom/text-projection.ts | 1 + .../tests/text-projection.test.ts | 18 ++++++++++++++++++ site/tests/browser/markdown-navigation.spec.ts | 13 +++++++++---- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/json-document-contenteditable/docs/editing.md b/packages/json-document-contenteditable/docs/editing.md index 299a56b6..6d0e361d 100644 --- a/packages/json-document-contenteditable/docs/editing.md +++ b/packages/json-document-contenteditable/docs/editing.md @@ -154,3 +154,9 @@ const unbind = binding.bind(); - 현재 계약은 수평 writing mode의 원문 문자열 편집입니다. 수직 writing mode 또는 DOM 측정이 없는 환경은 native에 맡깁니다. Alt/Ctrl/Meta 방향키, PageUp/Down, Home/End의 플랫폼 명령은 가로채지 않습니다. 구조화된 Rich Text의 node-point 선택 모델은 이 API의 입력 계약이 아닙니다. [Markdown caret Usage](/demo/markdown-caret)에서 제목·인용·목록·코드·표를 ↑↓와 Shift+↑↓로 이동할 수 있으며, Source에서 화면 줄 탐색과 caret 가시성 모듈까지 확인할 수 있습니다. 문서 원문·편집·History의 owner는 기존 Editing이고, 입력 이벤트 수명은 contenteditable binding에 남습니다. + +수평 투영 adapter는 자신이 처리하지 않는 기존 capability를 보존합니다. 따라서 +`createTextProjectionDOMAdapter(createTextNavigationDOMAdapter(base), projections)`도 +수직 이동과 `resetNavigation`을 유지합니다. 두 adapter의 `restoreSelection`은 바깥쪽에서 +안쪽으로 위임되므로 순서에 따라 투영 affinity·caret 표시의 적용 순서는 달라질 수 있습니다. +Markdown Web은 기존처럼 navigation이 projection을 감싸는 조합을 사용합니다. diff --git a/packages/json-document-contenteditable/src/dom/text-projection.ts b/packages/json-document-contenteditable/src/dom/text-projection.ts index cf412258..9deb499d 100644 --- a/packages/json-document-contenteditable/src/dom/text-projection.ts +++ b/packages/json-document-contenteditable/src/dom/text-projection.ts @@ -30,6 +30,7 @@ export function createTextProjectionDOMAdapter( if (root.hasAttribute("data-text-projection-caret") !== active) root.toggleAttribute("data-text-projection-caret", active); }; return { + ...base, observe: root => base.observe(root), render(root, value, selection = null) { base.render(root, value, selection); diff --git a/packages/json-document-contenteditable/tests/text-projection.test.ts b/packages/json-document-contenteditable/tests/text-projection.test.ts index daa09083..98392ff3 100644 --- a/packages/json-document-contenteditable/tests/text-projection.test.ts +++ b/packages/json-document-contenteditable/tests/text-projection.test.ts @@ -144,3 +144,21 @@ test("plain source rendering retains the live text node when only selection chan plainTextDOMAdapter.render(root, "changed"); expect(root.textContent).toBe("changed"); }); + + +test("projection preserves vertical navigation and its reset lifecycle", () => { + const {root} = fixture(); + let goal: number | null = null; + const base: TextDOMAdapter = {...plainTextDOMAdapter, + resolveVerticalSelection(_root, selection) { + goal ??= selection.focus; + return {anchor:goal, focus:goal}; + }, + resetNavigation() { goal = null; }, + }; + const adapter = createTextProjectionDOMAdapter(base, () => []); + expect(adapter.resolveVerticalSelection!(root, {anchor:3,focus:3}, "forward", false)?.focus).toBe(3); + expect(adapter.resolveVerticalSelection!(root, {anchor:1,focus:1}, "forward", false)?.focus).toBe(3); + adapter.resetNavigation!(root); + expect(adapter.resolveVerticalSelection!(root, {anchor:1,focus:1}, "forward", false)?.focus).toBe(1); +}); diff --git a/site/tests/browser/markdown-navigation.spec.ts b/site/tests/browser/markdown-navigation.spec.ts index 88719f4e..a15f4fcb 100644 --- a/site/tests/browser/markdown-navigation.spec.ts +++ b/site/tests/browser/markdown-navigation.spec.ts @@ -114,10 +114,10 @@ test("blank terminal lines remain reachable and pointer placement resets the hor await expect(editor).toHaveText(source + "end"); }); -test("the public navigation adapter handles 1000 plain lines and reveals a caret inside a scroll container", async ({page}) => { +for (const outerProjection of [false, true]) test(`the public navigation adapter handles 1000 plain lines (outer projection: ${outerProjection})`, async ({page}) => { await page.goto("/applications/bear"); const packageRoot = new URL("../../../packages/", import.meta.url).pathname; - await page.evaluate(async packageRoot => { + await page.evaluate(async ({packageRoot, outerProjection}) => { const contenteditable = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document-contenteditable/src/index.ts`); const core = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document/src/application/document/index.ts`); const editing = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document-editing/src/index.ts`); @@ -126,10 +126,11 @@ test("the public navigation adapter handles 1000 plain lines and reveals a caret root.style.cssText="position:fixed;top:20px;left:20px;width:360px;height:160px;overflow:auto;white-space:pre-wrap;line-height:28px;font:18px/28px monospace;background:white;z-index:9999"; document.body.append(root); const model = core.createJSONDocument(source), editor = editing.createTextEditor(model); - const dom = contenteditable.createTextNavigationDOMAdapter(contenteditable.plainTextDOMAdapter); + const navigation = contenteditable.createTextNavigationDOMAdapter(contenteditable.plainTextDOMAdapter); + const dom = outerProjection ? contenteditable.createTextProjectionDOMAdapter(navigation, () => []) : navigation; contenteditable.createContentEditableBinding({document:model,pointer:"",editor,root,dom}).bind(); root.focus(); editor.select({anchor:7,focus:7}); - }, packageRoot); + }, {packageRoot, outerProjection}); const editor = page.getByTestId("generic-navigation"); for(let i=0;i<22;i++) await page.keyboard.press("ArrowDown"); expect(await editor.evaluate(root=>root.scrollTop)).toBeGreaterThan(200); @@ -138,4 +139,8 @@ test("the public navigation adapter handles 1000 plain lines and reveals a caret expect(p.y+p.height).toBeLessThanOrEqual(box!.y+box!.height+1); for(let i=0;i<22;i++) await page.keyboard.press("ArrowUp"); expect((await point(editor)).focus).toBe(7); + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowUp"); + expect((await point(editor)).focus).toBe(6); }); From abd7d9442f47e10b3b4ecdf6b9183138feb86574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Fri, 11 Sep 2026 21:15:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(contenteditable):=20=ED=83=90=EC=83=89?= =?UTF-8?q?=20capability=EC=9D=98=20=EA=B3=B5=EA=B0=9C=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20reference=EC=97=90=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-reference/contenteditable.md | 50 +++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/api-reference/contenteditable.md b/docs/api-reference/contenteditable.md index e94bc35b..8d36a211 100644 --- a/docs/api-reference/contenteditable.md +++ b/docs/api-reference/contenteditable.md @@ -31,6 +31,8 @@ interface ContentEditableBindingOptions { readonly dom?: TextDOMAdapter; /** Optional canonical source editor; replaces direct commits with selection-restoring Editing transactions. */ readonly editor?: TextEditor; + /** Syntax-owned Enter command; paste and native composition text keep their original content. */ + readonly insertBreak?: (editor: TextEditor) => EditingResult; } ``` ## `ContentEditableBindingResult` @@ -53,7 +55,17 @@ interface ContentEditableProps { ## `createContentEditableBinding` ```ts -createContentEditableBinding({ document, dom, pointer, root, editor, }: ContentEditableBindingOptions): ContentEditableBinding +createContentEditableBinding({ document, dom, pointer, root, editor, insertBreak, }: ContentEditableBindingOptions): ContentEditableBinding +``` +## `createTextNavigationDOMAdapter` + +```ts +createTextNavigationDOMAdapter(base: TextDOMAdapter): TextDOMAdapter +``` +## `createTextProjectionDOMAdapter` + +```ts +createTextProjectionDOMAdapter(base: TextDOMAdapter, projections: (root: HTMLElement) => ReadonlyArray): TextDOMAdapter ``` ## `DOMObservation` @@ -73,13 +85,47 @@ const plainTextDOMAdapter: TextDOMAdapter ```ts renderTextCaretBoundary(root: HTMLElement, value: string): void ``` +## `restoreTextDOMSelection` + +```ts +restoreTextDOMSelection(root: HTMLElement, selection: TextSelection, options?: TextDOMSelectionOptions): boolean +``` ## `TextDOMAdapter` ```ts interface TextDOMAdapter { observe(root: HTMLElement): DOMObservation; render(root: HTMLElement, value: string, selection?: TextSelection | null): void; - restoreSelection(root: HTMLElement, selection: TextSelection): boolean; + restoreSelection(root: HTMLElement, selection: TextSelection, options?: TextDOMSelectionOptions): boolean; + /** Resolve one visual line while retaining the horizontal goal; null keeps native navigation. */ + resolveVerticalSelection?(root: HTMLElement, selection: TextSelection, direction: "backward" | "forward", extend: boolean): TextSelection | null; + /** Reset the horizontal goal after another input, pointer placement, or blur. */ + resetNavigation?(root: HTMLElement): void; + /** Resolve a source deletion range where native DOM deletion cannot preserve the projection. */ + resolveDeletionSelection?(root: HTMLElement, selection: TextSelection, direction: "backward" | "forward"): TextSelection | null; + /** Resolve a source-coordinate step across projected DOM boundaries; null keeps native navigation. */ + resolveHorizontalSelection?(root: HTMLElement, selection: TextSelection, direction: "backward" | "forward", extend: boolean): TextSelection | null; +} +``` +## `TextDOMSelectionOptions` + +```ts +interface TextDOMSelectionOptions { + /** Undefined preserves an equivalent live DOM endpoint; explicit affinity chooses a side. */ + readonly affinity?: (offset: number) => "backward" | "forward" | undefined; +} +``` +## `TextProjection` + +```ts +interface TextProjection { + readonly from: number; + readonly to: number; + readonly element: HTMLElement; + /** Optional next visible source position, after a concealed separator. */ + readonly following?: number; + /** Delete this displayed unit and its separator in one editing transaction. */ + readonly atomic?: boolean; } ``` ## `TextSelection`