From bfa11fb70811fed05534e5f051403bca0d246b27 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: Mon, 7 Sep 2026 23:50:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Document=20offset=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=9D=84=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EB=B6=80=ED=84=B0=20=EB=B3=B5=EC=9B=90?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=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/api-reference/web.md | 7 ++ docs/public/react-editing.md | 10 ++- packages/json-document-editing/README.md | 14 ++++ .../json-document-editing/src/document.ts | 4 +- .../tests/document-editor.test.ts | 73 ++++++++++++++++++- packages/json-document-react/README.md | 7 ++ .../src/use-document-text-control.ts | 26 +++++-- .../tests/react-connector.test.tsx | 38 ++++++++++ packages/json-document-web/README.md | 21 ++++++ packages/json-document-web/src/index.ts | 2 +- packages/json-document-web/src/input.ts | 13 ++++ .../tests/web-adapters.test.ts | 17 +++++ .../document-demo/DocumentDemoRoute.tsx | 13 ---- .../src/shared/demo-workbench/demo-sources.ts | 5 ++ site/tests/browser/document-demo.spec.ts | 23 ++++++ site/tests/unit/demo-workbench.test.tsx | 3 + 16 files changed, 251 insertions(+), 25 deletions(-) diff --git a/docs/api-reference/web.md b/docs/api-reference/web.md index 27e6f6e49..b39d5cb49 100644 --- a/docs/api-reference/web.md +++ b/docs/api-reference/web.md @@ -248,6 +248,11 @@ const sheetClipboardCodec: WebClipboardCodec ```ts textInputFromControl(event: WebTextControlEvent): WebTextInput ``` +## `textSelectionFromControl` + +```ts +textSelectionFromControl(event: WebTextControlEvent): SelectionRange +``` ## `treeClipboardCodec` ```ts @@ -798,6 +803,8 @@ webSVGViewportFromElement(svg: WebSVGElement): WebSVGViewport interface WebTextControl { readonly value: string; readonly selectionStart: number | null; + readonly selectionEnd?: number | null; + readonly selectionDirection?: "forward" | "backward" | "none" | null; } ``` ## `WebTextControlEvent` diff --git a/docs/public/react-editing.md b/docs/public/react-editing.md index 058a343f7..139e58119 100644 --- a/docs/public/react-editing.md +++ b/docs/public/react-editing.md @@ -248,7 +248,15 @@ import { DocumentTextControl } from "@interactive-os/json-document-react"; `useDocumentTextControl(options)`은 같은 계약을 `ref`와 `props`로 반환합니다. Host가 textarea element를 직접 구성해야 할 때 사용합니다. 두 API 모두 -Web의 `textInputFromControl`과 Affordance의 caret/click 계약을 합성합니다. +Web의 `textInputFromControl`, `textSelectionFromControl`과 Affordance의 +caret/click 계약을 합성합니다. `onCaretRange`의 `from`, `to`는 방향을 보존하는 +anchor, focus입니다. 역방향 선택에서는 `from > to`일 수 있습니다. + +네이티브 focus가 전달한 `offset`과 같으면 이미 선택된 범위를 유지하고, 다른 +`offset`이면 커서를 그 위치로 복원합니다. offset 하나로 과거의 네이티브 범위를 +재구성하지는 않습니다. `DocumentTextControl`이 있는 textarea의 방향키·Shift 선택은 +네이티브 입력이 담당하므로 외부 구조 탐색 binding에 `keyboard.text`를 중복 연결할 +필요가 없습니다. Host는 `rows`, class, 제품 문구와 실제 Document Intent dispatch를 소유합니다. Document selection의 primary point만 필요하면 Editing의 diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index d47698af7..7798ef908 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -178,3 +178,17 @@ targets may use numbered `marker` presentations for instructions or a serializable `{ type: "reaction", reaction: "like" | "dislike" }` presentation for comment-free feedback. Both use the same create, move, delete, and history contracts exposed by `createAnnotationEditor`. + +## Document offset selection + +For `selection.set`, replace/collapse and extend compare the complete +`DocumentPoint` (`blockId` and `offset`). Moving within one block updates the +caret; extension preserves the primary anchor and changes its focus, including +backward ranges. Offsets are clamped to the block's text bounds. + +`mode: "toggle"` continues to address the whole block independently of its text +offsets. Copy still projects whole selected blocks. Selection-only movement +preserves document values and existing Undo/Redo records; Undo after an edit +restores the recorded offset range. These contracts are exercised by +[Document editor tests](tests/document-editor.test.ts) and the existing +[Document Usage](https://developer-1px.github.io/json-document/demo). diff --git a/packages/json-document-editing/src/document.ts b/packages/json-document-editing/src/document.ts index adb3f7611..acda405f9 100644 --- a/packages/json-document-editing/src/document.ts +++ b/packages/json-document-editing/src/document.ts @@ -121,7 +121,9 @@ export function createDocumentEditor(source: EditingDocumentSource left.blockId === right.blockId, + // Toggle addresses whole blocks; caret/range endpoints also include offset. + (left, right) => left.blockId === right.blockId + && (intent.mode === "toggle" || left.offset === right.offset), ); return success(session.select(asDocumentSelection(selection))); } diff --git a/packages/json-document-editing/tests/document-editor.test.ts b/packages/json-document-editing/tests/document-editor.test.ts index 2df9adde1..225099f0f 100644 --- a/packages/json-document-editing/tests/document-editor.test.ts +++ b/packages/json-document-editing/tests/document-editor.test.ts @@ -1,7 +1,78 @@ import { describe, expect, test } from "vitest"; -import { createDocumentEditor, documentSelectionFocus } from "../src/index.js"; +import { createDocumentEditor, documentSelectionFocus, type DocumentSelection } from "../src/index.js"; describe("document editing vertical slice", () => { + test("moves, extends, and collapses offsets inside the same block without editing its contents", () => { + const initial = { blocks: [{ id: "a", text: "Alpha" }] }; + const editor = createDocumentEditor(initial); + const published: DocumentSelection[] = []; + const release = editor.subscribe((snapshot) => published.push(snapshot.selection)); + const selection = (anchor: number, focus = anchor): DocumentSelection => ({ + kind: "range", primaryIndex: 0, + ranges: [{ anchor: { blockId: "a", offset: anchor }, focus: { blockId: "a", offset: focus } }], + }); + const steps = [ + { offset: 2, mode: "replace", expected: selection(2) }, + { offset: 4, mode: "extend", expected: selection(2, 4) }, + { offset: 1, mode: "extend", expected: selection(2, 1) }, + { offset: 3, mode: "replace", expected: selection(3) }, + { offset: 99, mode: "extend", expected: selection(3, 5) }, + { offset: -1, mode: "extend", expected: selection(3, 0) }, + { offset: 99, mode: "replace", expected: selection(5) }, + { offset: -1, mode: "replace", expected: selection(0) }, + ] as const; + try { + for (const { offset, mode, expected } of steps) { + expect(editor.dispatch({ type: "selection.set", blockId: "a", offset, mode })) + .toMatchObject({ ok: true, snapshot: { value: initial, selection: expected, canUndo: false, canRedo: false } }); + expect(editor.snapshot.selection).toEqual(expected); + expect(editor.selectedBlockIds).toEqual(["a"]); + expect(editor.copy()?.blocks).toEqual(initial.blocks); + expect(published.at(-1)).toEqual(expected); + } + expect(published).toEqual(steps.map((step) => step.expected)); + } finally { release(); } + }); + + test("keeps block toggle independent of the selected offsets", () => { + const editor = createDocumentEditor({ blocks: [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }] }); + editor.dispatch({ type: "selection.set", blockId: "a", offset: 2 }); + editor.dispatch({ type: "selection.set", blockId: "a", offset: 4, mode: "extend" }); + const original = editor.snapshot.selection; + editor.dispatch({ type: "selection.set", blockId: "b", offset: 1, mode: "toggle" }); + expect(editor.selectedBlockIds).toEqual(["a", "b"]); + editor.dispatch({ type: "selection.set", blockId: "b", offset: 3, mode: "toggle" }); + expect(editor.snapshot.selection).toEqual(original); + expect(editor.selectedBlockIds).toEqual(["a"]); + editor.dispatch({ type: "selection.set", blockId: "a", offset: 0, mode: "toggle" }); + expect(editor.selectedBlockIds).toEqual([]); + expect(editor.snapshot.selection).toEqual({ kind: "range", ranges: [], primaryIndex: null }); + }); + + test("restores offset ranges on undo and preserves redo through caret-only movement", () => { + const initial = { blocks: [{ id: "a", text: "Alpha" }] }; + const editor = createDocumentEditor(initial); + editor.dispatch({ type: "selection.set", blockId: "a", offset: 4 }); + editor.dispatch({ type: "selection.set", blockId: "a", offset: 1, mode: "extend" }); + const before = { + value: initial, + selection: { kind: "range", primaryIndex: 0, ranges: [{ + anchor: { blockId: "a", offset: 4 }, focus: { blockId: "a", offset: 1 }, + }] }, + }; + expect(editor.snapshot).toMatchObject(before); + expect(editor.dispatch({ type: "text.replace", blockId: "a", text: "Alps", offset: 3 }).ok).toBe(true); + const after = editor.snapshot; + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { ...before, canUndo: false, canRedo: true } }); + expect(editor.dispatch({ type: "selection.set", blockId: "a", offset: 2 })) + .toMatchObject({ ok: true, snapshot: { value: initial, canUndo: false, canRedo: true } }); + expect(documentSelectionFocus(editor.snapshot.selection)?.offset).toBe(2); + expect(editor.redo()).toMatchObject({ ok: true, snapshot: { + value: after.value, selection: after.selection, canUndo: true, canRedo: false, + } }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { ...before, canUndo: false, canRedo: true } }); + }); + test("preserves group placement for every selection and direction up to six blocks", () => { for (let size = 1; size <= 6; size += 1) { for (let mask = 1; mask < 2 ** size; mask += 1) { diff --git a/packages/json-document-react/README.md b/packages/json-document-react/README.md index 7d8eafc1f..36fdfb9c0 100644 --- a/packages/json-document-react/README.md +++ b/packages/json-document-react/README.md @@ -46,6 +46,13 @@ textarea. `useDocumentTextControl` composes cursor restoration, Web text input, and caret/click affordances into reusable textarea props. `DocumentTextControl` renders that same lifecycle while the host keeps layout and Document Intent. +`onCaretRange` receives directional anchor/focus offsets projected by Web's +`textSelectionFromControl`, including backward selections. When native focus +already equals the supplied `offset`, the native range is preserved. A different +`offset` restores a collapsed caret through `restoreTextCursor`; an offset alone +does not encode a historical native range. Native text controls keep their own +arrow/Shift selection handling; the outer structural keyboard binding need not +also configure `keyboard.text` for those controls. `useGridEditing` is the grid-specific React entry point. It accepts canonical `GridPoint` values through `selectedPoints`, `focusPoint`, `onSelect`, and diff --git a/packages/json-document-react/src/use-document-text-control.ts b/packages/json-document-react/src/use-document-text-control.ts index 5f3adff39..ebb68e936 100644 --- a/packages/json-document-react/src/use-document-text-control.ts +++ b/packages/json-document-react/src/use-document-text-control.ts @@ -1,12 +1,12 @@ -import { createElement, useRef, type TextareaHTMLAttributes } from "react"; +import { createElement, useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react"; import { applyAffordance, caretAffordance, caretCursor, clickCountAffordance, } from "@interactive-os/json-document-affordance"; -import { textInputFromControl, type WebTextInput } from "@interactive-os/json-document-web"; -import { useRestoreTextCursor } from "./use-editing.js"; +import { textInputFromControl, textSelectionFromControl, type WebTextInput } from "@interactive-os/json-document-web"; +import { restoreTextCursor } from "./use-editing.js"; export interface UseDocumentTextControlOptions { readonly text: string; @@ -32,21 +32,30 @@ export interface DocumentTextControlProps extends UseDocumentTextControlOptions, /** Composes the official Web input and caret affordances into a React textarea lifecycle. */ export function useDocumentTextControl(options: UseDocumentTextControlOptions): DocumentTextControlBinding { const ref = useRef(null); - useRestoreTextCursor(ref, options.offset); + useLayoutEffect(() => { + const control = ref.current; + if (control === null || options.offset === null) return; + // Native selection already owns the range when its focus matches the model. + // Echoing that focus as a collapsed cursor would emit another select event. + if (textSelectionFromControl({ currentTarget: control }).focus !== options.offset) { + restoreTextCursor(control, options.offset); + } + }, [options.offset]); return { ref, props: { value: options.text, onFocus(event) { - const offset = textInputFromControl(event).offset; - options.onCaretRange(offset, offset, "replace"); + const { anchor, focus } = textSelectionFromControl(event); + options.onCaretRange(anchor, focus, "replace"); }, onClick(event) { applyAffordance(caretAffordance({ type: "pointer" }), { hand(hand) { if (hand.type === "caret") { - options.onCaretRange(event.currentTarget.selectionStart, event.currentTarget.selectionEnd, hand.operation); + const { anchor, focus } = textSelectionFromControl(event); + options.onCaretRange(anchor, focus, hand.operation); } }, }); @@ -60,7 +69,8 @@ export function useDocumentTextControl(options: UseDocumentTextControlOptions): applyAffordance(caretAffordance({ type: "pointer", dragging: true }), { hand(hand) { if (hand.type === "caret") { - options.onCaretRange(event.currentTarget.selectionStart, event.currentTarget.selectionEnd, hand.operation); + const { anchor, focus } = textSelectionFromControl(event); + options.onCaretRange(anchor, focus, hand.operation); } }, }); diff --git a/packages/json-document-react/tests/react-connector.test.tsx b/packages/json-document-react/tests/react-connector.test.tsx index 400826bd6..02b0cd9db 100644 --- a/packages/json-document-react/tests/react-connector.test.tsx +++ b/packages/json-document-react/tests/react-connector.test.tsx @@ -57,6 +57,44 @@ describe("React Connector", () => { expect(inputs).toEqual([{ text: "Alps", offset: 4 }]); }); + test("keeps native directional ranges when Document publishes their focus offset", () => { + const editor = createDocumentEditor({ blocks: [{ id: "a", text: "Alpha" }] }); + function View() { + const snapshot = useEditingSnapshot(editor); + return { + editor.dispatch({ type: "selection.set", blockId: "a", offset: anchor }); + if (mode === "extend" || anchor !== focus) { + editor.dispatch({ type: "selection.set", blockId: "a", offset: focus, mode: "extend" }); + } + }} + onTextInput={() => {}} + />; + } + render(); + const control = screen.getByRole("textbox", { name: "Bound Document text" }) as HTMLTextAreaElement; + act(() => { control.focus(); }); + for (const [start, end, direction, anchor, focus] of [ + [2, 4, "forward", 2, 4], + [1, 2, "backward", 2, 1], + [3, 3, "none", 3, 3], + ] as const) { + control.setSelectionRange(start, end, direction); + fireEvent.select(control); + expect(editor.snapshot.selection.ranges).toEqual([{ + anchor: { blockId: "a", offset: anchor }, focus: { blockId: "a", offset: focus }, + }]); + expect([control.selectionStart, control.selectionEnd]).toEqual([start, end]); + if (direction !== "none") expect(control.selectionDirection).toBe(direction); + } + expect(editor.snapshot).toMatchObject({ value: { blocks: [{ id: "a", text: "Alpha" }] }, canUndo: false, canRedo: false }); + act(() => { editor.dispatch({ type: "selection.set", blockId: "a", offset: 0 }); }); + expect([control.selectionStart, control.selectionEnd]).toEqual([0, 0]); + }); + test("exposes the shared document through the official Connector entry point", () => { const document = createJSONDocument({ title: "Draft" }); function View() { diff --git a/packages/json-document-web/README.md b/packages/json-document-web/README.md index 74391a851..1716c249f 100644 --- a/packages/json-document-web/README.md +++ b/packages/json-document-web/README.md @@ -202,3 +202,24 @@ so non-browser tooling can load it safely. | --- | --- | | `@interactive-os/json-document-editing` | `>=0.1.0-rc.0 <1` | | `@interactive-os/json-document-selection` | `>=0.1.0-rc.0 <1` | + +## Native text selection + +`textSelectionFromControl({ currentTarget })` projects an input or textarea's +`selectionStart`, `selectionEnd`, and `selectionDirection` into the existing +`SelectionRange` anchor/focus contract. A backward native selection has +its anchor at the end and focus at the start. Bounds are clamped to the text; +a control without end/direction remains a collapsed selection. The existing +`textInputFromControl` text/offset result is unchanged. + +```ts +import { textSelectionFromControl } from "@interactive-os/json-document-web"; + +const range = textSelectionFromControl({ currentTarget: textarea }); +// range.anchor and range.focus preserve the native selection direction. +``` + +`DocumentTextControl` consumes this public projection in the live +[Document Usage](https://developer-1px.github.io/json-document/demo); its source +view links the React binding to this package's `input.ts` implementation and +[API reference](https://developer-1px.github.io/json-document/docs/api/web). diff --git a/packages/json-document-web/src/index.ts b/packages/json-document-web/src/index.ts index c928226ab..15ba7e06e 100644 --- a/packages/json-document-web/src/index.ts +++ b/packages/json-document-web/src/index.ts @@ -11,7 +11,7 @@ export { treeClipboardCodec, } from "./clipboard.js"; export { selectionOperationFromModifiers } from "./modifiers.js"; -export { isWebEditableTarget, isWebEditingHostTarget, textInputFromControl } from "./input.js"; +export { isWebEditableTarget, isWebEditingHostTarget, textInputFromControl, textSelectionFromControl } from "./input.js"; export { pressInteractionFromWeb } from "./press.js"; export { focusWebItem, webFocusItemProps } from "./focus-item.js"; export { findWebGridCell, webGridCellAddressProps } from "./grid-cell.js"; diff --git a/packages/json-document-web/src/input.ts b/packages/json-document-web/src/input.ts index 6749eec26..ab6e04908 100644 --- a/packages/json-document-web/src/input.ts +++ b/packages/json-document-web/src/input.ts @@ -1,6 +1,10 @@ +import type { SelectionRange } from "@interactive-os/json-document-selection"; + export interface WebTextControl { readonly value: string; readonly selectionStart: number | null; + readonly selectionEnd?: number | null; + readonly selectionDirection?: "forward" | "backward" | "none" | null; } export interface WebTextControlEvent { @@ -18,6 +22,15 @@ export function textInputFromControl(event: WebTextControlEvent): WebTextInput { return { text, offset: Math.min(text.length, Math.max(0, offset)) }; } +/** Projects a native text control's directional selection into anchor/focus offsets. */ +export function textSelectionFromControl(event: WebTextControlEvent): SelectionRange { + const { text, offset: start } = textInputFromControl(event); + const end = Math.min(text.length, Math.max(start, event.currentTarget.selectionEnd ?? start)); + return event.currentTarget.selectionDirection === "backward" + ? { anchor: end, focus: start } + : { anchor: start, focus: end }; +} + export function isWebEditableTarget(target: object | null): boolean { if (!(target instanceof Element)) return false; return target instanceof HTMLInputElement diff --git a/packages/json-document-web/tests/web-adapters.test.ts b/packages/json-document-web/tests/web-adapters.test.ts index a4d52a3c5..3b93bd467 100644 --- a/packages/json-document-web/tests/web-adapters.test.ts +++ b/packages/json-document-web/tests/web-adapters.test.ts @@ -46,6 +46,7 @@ import { selectionOperationFromModifiers, sheetClipboardCodec, textInputFromControl, + textSelectionFromControl, webFocusItemProps, webGridCellAddressProps, webKanbanCardProps, @@ -54,6 +55,22 @@ import { type WebClipboardEvent, } from "../src/index.js"; +describe("native text selection projection", () => { + test.each([ + { selectionStart: 2, selectionEnd: 4, selectionDirection: "forward", expected: { anchor: 2, focus: 4 } }, + { selectionStart: 1, selectionEnd: 4, selectionDirection: "backward", expected: { anchor: 4, focus: 1 } }, + { selectionStart: 2, selectionEnd: 2, selectionDirection: "none", expected: { anchor: 2, focus: 2 } }, + { selectionStart: -1, selectionEnd: 99, selectionDirection: "backward", expected: { anchor: 5, focus: 0 } }, + { selectionStart: null, selectionEnd: null, selectionDirection: null, expected: { anchor: 5, focus: 5 } }, + ] as const)("preserves anchor/focus for $selectionDirection $selectionStart:$selectionEnd", ({ expected, ...selection }) => { + expect(textSelectionFromControl({ currentTarget: { value: "Alpha", ...selection } })).toEqual(expected); + }); + + test("accepts an existing cursor-only control as a collapsed selection", () => { + expect(textSelectionFromControl({ currentTarget: { value: "Alpha", selectionStart: 2 } })).toEqual({ anchor: 2, focus: 2 }); + }); +}); + describe("Web file intake translation", () => { const files = [ { name: "brief.png", size: 24, type: "image/png" }, diff --git a/site/src/routes/document-demo/DocumentDemoRoute.tsx b/site/src/routes/document-demo/DocumentDemoRoute.tsx index 4e5be68c1..9b417f6b4 100644 --- a/site/src/routes/document-demo/DocumentDemoRoute.tsx +++ b/site/src/routes/document-demo/DocumentDemoRoute.tsx @@ -107,19 +107,6 @@ export function DocumentDemoRoute() { onRedo: () => { run(() => editor.redo(), "Redone"); }, - text: { - offset: () => documentSelectionFocus(editor.snapshot.selection)?.offset ?? 0, - length: () => { - const blockId = documentSelectionFocus(editor.snapshot.selection)?.blockId; - const block = (editor.snapshot.value as BlockDocument).blocks.find((item) => item.id === blockId); - return block?.text.length ?? 0; - }, - onOffset: (offset, mode) => { - const blockId = documentSelectionFocus(editor.snapshot.selection)?.blockId; - if (!blockId) return; - run(() => dispatchIntent({ type: "selection.set", blockId, mode, offset }), "Selection changed"); - }, - }, }, }); const snapshot = editing.snapshot; diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index e76ad76f8..352e3be9c 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -293,6 +293,11 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredPublicUsages = [ + { + packageName: "@interactive-os/json-document-web", + symbol: "textSelectionFromControl", + sourcePath: "packages/json-document-web/src/input.ts", + }, { packageName: "@interactive-os/json-document-web", symbol: "isWebEditingHostTarget", diff --git a/site/tests/browser/document-demo.spec.ts b/site/tests/browser/document-demo.spec.ts index c874f8d2e..d16ff59ef 100644 --- a/site/tests/browser/document-demo.spec.ts +++ b/site/tests/browser/document-demo.spec.ts @@ -1,5 +1,28 @@ import { expect, test, type Page } from "@playwright/test"; +test("Document keeps native caret and directional range offsets in the editor", async ({ page }) => { + await page.goto("/demo"); + await page.getByText("Inspect editing state", { exact: true }).click(); + const before = await canonicalDocument(page); + const text = page.getByRole("textbox", { name: "Block 1 text" }); + const intent = async () => JSON.parse(await page.getByTestId("document-intent-json").innerText()); + await text.focus(); + await text.press("ArrowRight"); + await expect.poll(intent).toMatchObject({ type: "selection.set", blockId: "welcome", offset: 1 }); + await text.press("ArrowRight"); + await expect.poll(intent).toMatchObject({ type: "selection.set", blockId: "welcome", offset: 2 }); + await text.press("Shift+ArrowRight"); + await expect.poll(intent).toMatchObject({ type: "selection.set", blockId: "welcome", offset: 3 }); + await expect.poll(() => text.evaluate((node: HTMLTextAreaElement) => [node.selectionStart, node.selectionEnd])).toEqual([2, 3]); + await text.press("Shift+ArrowLeft"); + await expect.poll(intent).toMatchObject({ type: "selection.set", blockId: "welcome", offset: 2 }); + await text.press("Shift+ArrowLeft"); + await expect.poll(intent).toMatchObject({ type: "selection.set", blockId: "welcome", offset: 1 }); + await expect.poll(() => text.evaluate((node: HTMLTextAreaElement) => [node.selectionStart, node.selectionEnd, node.selectionDirection])).toEqual([1, 2, "backward"]); + expect(await canonicalDocument(page)).toEqual(before); + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); +}); + test("minimal document demo completes selection, clipboard, edit, move, undo, and redo", async ({ page }) => { await page.goto("/demo"); await page.getByText("Inspect editing state", { exact: true }).click(); diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index f9c8af68d..b510fedfc 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -89,6 +89,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-react/src/editing-observation.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", + "packages/json-document-web/src/input.ts", "packages/json-document-editing/src/document.ts", ]); const source = await document[0]!.load(); @@ -101,6 +102,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-react/src/editing-observation.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", + "packages/json-document-web/src/input.ts", "packages/json-document-editing/src/document.ts", ]); expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false); @@ -111,6 +113,7 @@ describe("Demo definition and source discovery", () => { "/docs/api/react", "/docs/api/web", "/docs/api/react", + "/docs/api/web", "/docs/api/editing", ]); }); From 18f290ccda05513d4e78e7bd917dd46d4f3056ea 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: Tue, 8 Sep 2026 00:09:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20Document=20offset=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EC=A0=81=ED=95=A9=EC=84=B1=20=EC=A6=9D?= =?UTF-8?q?=EA=B1=B0=EC=99=80=20=EC=9E=85=EB=A0=A5=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/json-document-web/README.md | 5 +++-- standards/editing-grammar.md | 20 +++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/json-document-web/README.md b/packages/json-document-web/README.md index 8ddec0747..ac5dfe8d3 100644 --- a/packages/json-document-web/README.md +++ b/packages/json-document-web/README.md @@ -223,8 +223,9 @@ port; they do not certify browser-specific clipboard permissions or transport. `textSelectionFromControl({ currentTarget })` projects an input or textarea's `selectionStart`, `selectionEnd`, and `selectionDirection` into the existing `SelectionRange` anchor/focus contract. A backward native selection has -its anchor at the end and focus at the start. Bounds are clamped to the text; -a control without end/direction remains a collapsed selection. The existing +its anchor at the end and focus at the start. Bounds are clamped to the text. +A missing `selectionEnd` produces a collapsed selection; a missing direction +uses start as anchor and end as focus. The existing `textInputFromControl` text/offset result is unchanged. ```ts diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md index 68ba0d03d..1cdc03b37 100644 --- a/standards/editing-grammar.md +++ b/standards/editing-grammar.md @@ -251,14 +251,20 @@ Usage/source 등록은 기존 Editing·Selection·Affordance·Web·Rich Text own native caret/IME·브라우저 Clipboard 권한, 모든 Host callback, 협업 History의 전체 수렴을 보증하지 않는다. #719의 세션 관찰·복구·협업 History 보완은 별도 변경이다. -동결 전 남아 있는 관찰 사례도 구분한다. 초기 Document가 +초기 적합성 작업에서 관찰한 Document offset 공백은 별도 수정과 회귀 증거로 +연결했다. 초기 Document가 `{ blocks: [{ id: "a", text: "Alpha" }] }`일 때 -`dispatch({ type: "selection.set", blockId: "a", offset: 2 })`는 성공을 반환하지만 -현재 같은 블록의 선택 offset은 0에 남는다. `document.ts`가 range 전이의 point -동등성을 block ID로 판단하는 경로다. 이번 Document fixture는 블록 간 선택과 -전체 블록 Copy를 검증하며 이 동작을 text-caret의 영구 규칙으로 채택하지 않는다. -이 재현 사례는 관찰된 공백으로 남기며, 이번 변경에서 runtime 동작을 바꾸거나 -동결하지 않는다. +`dispatch({ type: "selection.set", blockId: "a", offset: 2 })`의 선택이 0에 남던 +원인은 point 동등성을 block ID만으로 판단한 것이었다. 현재 replace/extend는 +offset도 비교하며 블록 toggle과 전체 블록 Copy의 의미는 유지한다. +[Document 회귀](../packages/json-document-editing/tests/document-editor.test.ts)는 +같은 블록의 양방향 확장·collapse·경계 보정·History 보존과 Undo의 범위 복원을 +검증한다. [Web 입력 투영](../packages/json-document-web/tests/web-adapters.test.ts), +[React 연결](../packages/json-document-react/tests/react-connector.test.tsx), +[Document 브라우저 경로](../site/tests/browser/document-demo.spec.ts)는 네이티브 +방향을 anchor/focus로 전달하고 model이 focus를 발행할 때 기존 native range를 +유지하는 증거다. offset 하나로 과거 native range 전체를 복원한다는 계약이나 +profile 동결로 확대하지 않는다. ## 장기 호환성