From d046c25d2de4beafe015f9c6e0fed86bcf1119d6 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: Sun, 13 Sep 2026 11:32:47 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Bear=EC=99=80=20Sheet=EC=9D=98=20?= =?UTF-8?q?=ED=91=9C=20=ED=8E=B8=EC=A7=91=20UI=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20Hand=EB=A1=9C=20=EC=97=B0=EA=B2=B0=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture/modules.json | 11 + docs/public/hands-support.md | 1 + package-lock.json | 43 ++- package.json | 3 +- .../json-document-contenteditable/README.md | 4 + .../src/dom/text-index.ts | 5 +- .../tests/text-decoration.test.ts | 11 + .../docs/api-reference.md | 14 +- packages/json-document-editing/src/index.ts | 1 + packages/json-document-editing/src/sheet.ts | 44 ++- .../tests/sheet-structure.test.ts | 19 ++ .../docs/api-reference.md | 5 + .../docs/editing.md | 4 + .../json-document-markdown-react/package.json | 9 +- .../src/MarkdownEditingSurface.tsx | 23 +- .../json-document-markdown-react/src/index.ts | 1 + .../src/markdown-table-editor.ts | 71 +++++ .../tests/markdown-table-editor.test.ts | 37 +++ .../tsconfig.json | 6 + .../docs/api-reference.md | 5 +- .../src/editing-binding.ts | 7 +- .../src/markdown-dom.ts | 15 +- .../src/markdown-editor.css | 3 + .../src/source-runs.ts | 13 +- .../docs/api-reference.md | 25 ++ packages/json-document-markdown/src/index.ts | 1 + .../src/table-editing.ts | 35 +++ .../tests/table-editing.test.ts | 11 + packages/json-document-sheet/LICENSE | 21 ++ packages/json-document-sheet/README.md | 3 + .../json-document-sheet/docs/api-reference.md | 25 ++ packages/json-document-sheet/docs/editing.md | 21 ++ packages/json-document-sheet/package.json | 64 ++++ packages/json-document-sheet/src/index.ts | 1 + .../json-document-sheet/src/sheet-hand.tsx | 104 +++++++ .../tests/sheet-hand.test.tsx | 26 ++ packages/json-document-sheet/tsconfig.json | 29 ++ .../json-document-sheet/tsconfig.test.json | 5 + packages/json-document-sheet/vitest.config.ts | 7 + site/config/json-document-source-aliases.ts | 1 + site/package.json | 3 +- site/site-routes.json | 16 + site/src/app/routeTree.gen.ts | 21 ++ site/src/app/routes/_page/docs/api/sheet.tsx | 8 + site/src/routes/docs/DocsRoute.tsx | 1 + site/src/routes/docs/doc-pages.ts | 1 + site/src/routes/sheet-demo/SheetDemo.tsx | 282 ++---------------- .../src/shared/demo-workbench/demo-sources.ts | 18 ++ site/tests/browser/sheet-demo.spec.ts | 229 +++++--------- site/tests/unit/demo-workbench.test.tsx | 23 +- site/tsconfig.json | 1 + standards/repository-implementation-shape.md | 1 + tsconfig.build.json | 3 +- 53 files changed, 880 insertions(+), 461 deletions(-) create mode 100644 packages/json-document-contenteditable/tests/text-decoration.test.ts create mode 100644 packages/json-document-editing/tests/sheet-structure.test.ts create mode 100644 packages/json-document-markdown-react/src/markdown-table-editor.ts create mode 100644 packages/json-document-markdown-react/tests/markdown-table-editor.test.ts create mode 100644 packages/json-document-markdown/src/table-editing.ts create mode 100644 packages/json-document-markdown/tests/table-editing.test.ts create mode 100644 packages/json-document-sheet/LICENSE create mode 100644 packages/json-document-sheet/README.md create mode 100644 packages/json-document-sheet/docs/api-reference.md create mode 100644 packages/json-document-sheet/docs/editing.md create mode 100644 packages/json-document-sheet/package.json create mode 100644 packages/json-document-sheet/src/index.ts create mode 100644 packages/json-document-sheet/src/sheet-hand.tsx create mode 100644 packages/json-document-sheet/tests/sheet-hand.test.tsx create mode 100644 packages/json-document-sheet/tsconfig.json create mode 100644 packages/json-document-sheet/tsconfig.test.json create mode 100644 packages/json-document-sheet/vitest.config.ts create mode 100644 site/src/app/routes/_page/docs/api/sheet.tsx diff --git a/architecture/modules.json b/architecture/modules.json index 56c052a3c..1d5ea61d2 100644 --- a/architecture/modules.json +++ b/architecture/modules.json @@ -410,5 +410,16 @@ "entrypoint": "packages/contenteditable-collaboration/src/index.ts", "subpaths": [], "referencePath": "packages/contenteditable-collaboration/docs/api-reference.md" + }, + { + "packageName": "@interactive-os/json-document-sheet", + "sourceDirectory": "packages/json-document-sheet", + "positions": [ + "Hands" + ], + "responsibility": "Sheet와 Markdown 표의 셀 선택·편집·구조 조작 UI 조합", + "entrypoint": "packages/json-document-sheet/src/index.ts", + "subpaths": [], + "referencePath": "packages/json-document-sheet/docs/api-reference.md" } ] diff --git a/docs/public/hands-support.md b/docs/public/hands-support.md index 93e41c376..9aea9e31c 100644 --- a/docs/public/hands-support.md +++ b/docs/public/hands-support.md @@ -14,6 +14,7 @@ Hands는 장르의 문서·편집·입력·UI를 조합한 편집 경험입니 ## 현재 근거 - [Calendar](../../packages/json-document-calendar/docs/api-reference.md): 공개 Hand 계약과 [Calendar 예제](hands.md#calendar-editor)를 확인할 수 있습니다. +- [Sheet](../../packages/json-document-sheet/docs/api-reference.md): [표 편집 예제](/demo/sheet)와 [Bear](/applications/bear)가 셀 이동·편집·행/열 조작 UI를 공유합니다. - [Annotation](../../packages/json-document-annotation/docs/api-reference.md): 공개 Hand 계약과 [Annotation 예제](hands.md#annotation-editor)를 확인할 수 있습니다. - [Rich Text](/editing/rich-text): 문서·편집·Web·React를 연결한 대표 편집 조합입니다. - 다른 장르는 [Hands](hands.md)의 예제와 모듈 API에서 각각 지원 범위를 확인합니다. diff --git a/package-lock.json b/package-lock.json index 20f66b412..c1c0bd138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "packages/json-document-rich-text-react", "packages/json-document-collaboration", "packages/contenteditable-collaboration", - "site" + "site", + "packages/json-document-sheet" ], "devDependencies": { "@playwright/test": "^1.60.0", @@ -1234,6 +1235,10 @@ "resolved": "packages/json-document-selection", "link": true }, + "node_modules/@interactive-os/json-document-sheet": { + "resolved": "packages/json-document-sheet", + "link": true + }, "node_modules/@interactive-os/json-document-site": { "resolved": "site", "link": true @@ -6867,7 +6872,9 @@ }, "devDependencies": { "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-markdown": "*", "@interactive-os/json-document-markdown-web": "*", + "@interactive-os/json-document-sheet": "*", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -6880,8 +6887,11 @@ }, "peerDependencies": { "@interactive-os/json-document-editing": "^0.1.0-rc.0", + "@interactive-os/json-document-markdown": "^0.1.0-rc.0", "@interactive-os/json-document-markdown-web": "^0.1.0-rc.0", - "react": "^18.0.0 || ^19.0.0" + "@interactive-os/json-document-sheet": "^0.1.0-rc.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" } }, "packages/json-document-markdown-web": { @@ -7127,6 +7137,34 @@ "vitest": "^4.1.7" } }, + "packages/json-document-sheet": { + "name": "@interactive-os/json-document-sheet", + "version": "0.1.0-rc.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-react": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-tanstack-table": { "name": "@interactive-os/json-document-tanstack-table", "version": "0.1.0-rc.0", @@ -7244,6 +7282,7 @@ "@interactive-os/json-document-rich-text-suggestion-react": "*", "@interactive-os/json-document-rich-text-web": "*", "@interactive-os/json-document-selection": "*", + "@interactive-os/json-document-sheet": "*", "@interactive-os/json-document-tanstack-table": "*", "@interactive-os/json-document-ui-primitives-react": "*", "@interactive-os/json-document-web": "*", diff --git a/package.json b/package.json index c81ccbfbb..02a57282b 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "packages/json-document-rich-text-react", "packages/json-document-collaboration", "packages/contenteditable-collaboration", - "site" + "site", + "packages/json-document-sheet" ], "scripts": { "dev": "npm run dev -w @interactive-os/json-document-site", diff --git a/packages/json-document-contenteditable/README.md b/packages/json-document-contenteditable/README.md index fa53ca0c6..15c05b99b 100644 --- a/packages/json-document-contenteditable/README.md +++ b/packages/json-document-contenteditable/README.md @@ -32,3 +32,7 @@ For selection-restoring history and literal source clipboard, pass the optional For source intervals displayed as one visual unit, use `createTextProjectionDOMAdapter`. Its [projection contract](docs/editing.md#원문-구간의-시각적-투영) preserves source editing while aligning caret edges to the displayed element. + +Source-preserving DOM adapters may mark a noneditable UI island with `data-text-decoration`. +The text index excludes that subtree from source and source selections. Keep the original +source in a separate text projection; UI labels must never be serialized as document text. diff --git a/packages/json-document-contenteditable/src/dom/text-index.ts b/packages/json-document-contenteditable/src/dom/text-index.ts index 42b971554..246204146 100644 --- a/packages/json-document-contenteditable/src/dom/text-index.ts +++ b/packages/json-document-contenteditable/src/dom/text-index.ts @@ -18,7 +18,7 @@ export function textDOMIndex(root: HTMLElement): TextDOMIndex { if (!cached) { const observer = new MutationObserver(records => { if (records.length) cached!.dirty = true; }); cached = { observer, dirty: true, index: null }; - observer.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["data-contenteditable-caret"] }); + observer.observe(root, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["data-contenteditable-caret", "data-text-decoration"] }); indexes.set(root, cached); } if (cached.dirty || cached.observer.takeRecords().length) { @@ -33,7 +33,8 @@ function buildIndex(root: HTMLElement): TextDOMIndex { const projections = new Map(); const project = (node: Node): NodeText => { let result: NodeText; - if (node.nodeType === 3) result = { value: (node as Text).data, children: [], offsets: [] }; + if (node.nodeType === 1 && (node as Element).hasAttribute("data-text-decoration")) result = { value: "", children: [], offsets: [] }; + else if (node.nodeType === 3) result = { value: (node as Text).data, children: [], offsets: [] }; else if (isElement(node, "br")) result = { value: (node as Element).hasAttribute("data-contenteditable-caret") ? "" : "\n", children: [], offsets: [0] }; else { const parts: string[] = []; diff --git a/packages/json-document-contenteditable/tests/text-decoration.test.ts b/packages/json-document-contenteditable/tests/text-decoration.test.ts new file mode 100644 index 000000000..67e63636a --- /dev/null +++ b/packages/json-document-contenteditable/tests/text-decoration.test.ts @@ -0,0 +1,11 @@ +import {expect, test} from "vitest"; +import {plainTextDOMAdapter} from "../src/index.js"; +test("UI islands cannot contaminate source or source selections", () => { + const root = document.createElement("div"); + root.innerHTML = 'before
toolbar
UI label
after'; + expect(plainTextDOMAdapter.observe(root).value).toBe("beforeafter"); + root.querySelector("td")!.textContent = "changed"; + expect(plainTextDOMAdapter.observe(root).value).toBe("beforeafter"); + root.querySelector('[data-text-decoration]')!.removeAttribute('data-text-decoration'); + expect(plainTextDOMAdapter.observe(root).value).toContain("changed"); +}); diff --git a/packages/json-document-editing/docs/api-reference.md b/packages/json-document-editing/docs/api-reference.md index bd77e56b6..79d6caa93 100644 --- a/packages/json-document-editing/docs/api-reference.md +++ b/packages/json-document-editing/docs/api-reference.md @@ -634,7 +634,7 @@ createOrderEditor(source: EditingDocumentSource, options?: Editin ## `createSheetEditor` ```ts -createSheetEditor(source: EditingDocumentSource, options?: EditingHistoryOptions): SheetEditor +createSheetEditor(source: EditingDocumentSource, options?: SheetEditorOptions): SheetEditor ``` ## `createTextEditor` @@ -1547,10 +1547,22 @@ interface SheetEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } ``` +## `SheetEditorOptions` + +```ts +interface SheetEditorOptions extends EditingHistoryOptions { + /** Restore selection when projecting a new source snapshot; missing cells are reconciled. */ + readonly selection?: SheetSelection; +} +``` ## `SheetIntent` ```ts type SheetIntent = + | { readonly type: "row.insert"; readonly index: number; readonly row: SheetRow } + | { readonly type: "row.delete"; readonly rowId: string } + | { readonly type: "column.insert"; readonly index: number; readonly column: SheetColumn } + | { readonly type: "column.delete"; readonly columnId: string } | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 1a9ad5489..212f77c11 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -158,6 +158,7 @@ export type { SheetColumn, SheetDocument, SheetEditor, + SheetEditorOptions, SheetIntent, SheetPoint, SheetRange, diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index a055e9042..0bbeb8857 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -84,6 +84,10 @@ export const sheetClipboardFormat = { }; export type SheetIntent = + | { readonly type: "row.insert"; readonly index: number; readonly row: SheetRow } + | { readonly type: "row.delete"; readonly rowId: string } + | { readonly type: "column.insert"; readonly index: number; readonly column: SheetColumn } + | { readonly type: "column.delete"; readonly columnId: string } | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; @@ -120,15 +124,20 @@ export interface SheetEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export function createSheetEditor(source: EditingDocumentSource, options: EditingHistoryOptions = {}): SheetEditor { +export interface SheetEditorOptions extends EditingHistoryOptions { + /** Restore selection when projecting a new source snapshot; missing cells are reconciled. */ + readonly selection?: SheetSelection; +} + +export function createSheetEditor(source: EditingDocumentSource, options: SheetEditorOptions = {}): SheetEditor { const document = resolveDocumentSource(source); const initial = document.value as SheetDocument; assertSheetDocument(initial); const firstRow = initial.rows[0]; const firstColumn = initial.columns[0]; - const initialSelection = firstRow && firstColumn - ? collapsed(firstRow.id, firstColumn.id) - : emptySelection(); + const initialSelection = options.selection + ? withPrimaryAliases(reconcileRangeSelection(options.selection, point => initial.rows.some(row => row.id === point.rowId) && initial.columns.some(column => column.id === point.columnId) ? point : null)) + : firstRow && firstColumn ? collapsed(firstRow.id, firstColumn.id) : emptySelection(); const session = createEditingSession({ ...options, document, @@ -198,6 +207,33 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + if (intent.type === "row.insert" || intent.type === "row.delete" || intent.type === "column.insert" || intent.type === "column.delete") { + const current = value(); + let rows = [...current.rows], columns = [...current.columns]; + if (intent.type === "row.insert") { + if (!Number.isInteger(intent.index) || intent.index < 0 || intent.index > rows.length || rows.some(row => row.id === intent.row.id) || !intent.row.id) return failure("row.invalid-insert"); + if (columns.some(column => !Object.hasOwn(intent.row.cells, column.id))) return failure("row.missing-cell"); + rows.splice(intent.index, 0, intent.row); + } else if (intent.type === "column.insert") { + if (!Number.isInteger(intent.index) || intent.index < 0 || intent.index > columns.length || columns.some(column => column.id === intent.column.id) || !intent.column.id) return failure("column.invalid-insert"); + columns.splice(intent.index, 0, intent.column); + rows = rows.map(row => ({...row, cells: {...row.cells, [intent.column.id]: ""}})); + } else if (intent.type === "row.delete") { + if (!rows.some(row => row.id === intent.rowId)) return failure("row.not-found"); + rows = rows.filter(row => row.id !== intent.rowId); + } else { + if (!columns.some(column => column.id === intent.columnId)) return failure("column.not-found"); + columns = columns.filter(column => column.id !== intent.columnId); + rows = rows.map(row => { const cells = {...row.cells}; delete cells[intent.columnId]; return {...row, cells}; }); + } + const focus = session.snapshot.selection.focus; + const oldRow = current.rows.findIndex(row => row.id === focus?.rowId); + const oldColumn = current.columns.findIndex(column => column.id === focus?.columnId); + const row = rows.find(row => row.id === focus?.rowId) ?? rows[Math.min(Math.max(oldRow, 0), rows.length - 1)]; + const column = columns.find(column => column.id === focus?.columnId) ?? columns[Math.min(Math.max(oldColumn, 0), columns.length - 1)]; + return session.apply({operations: [{op: "replace", path: "/rows", value: rows}, {op: "replace", path: "/columns", value: columns}], + selectionAfter: row && column ? collapsed(row.id, column.id) : emptySelection(), origin: intent.type}); + } if (intent.type === "selection.select-all") { const { rowIds, columnIds } = resolveTopology(value(), intent.topology, index()); const firstRow = rowIds[0]; diff --git a/packages/json-document-editing/tests/sheet-structure.test.ts b/packages/json-document-editing/tests/sheet-structure.test.ts new file mode 100644 index 000000000..b3960b90d --- /dev/null +++ b/packages/json-document-editing/tests/sheet-structure.test.ts @@ -0,0 +1,19 @@ +import {expect, test} from "vitest"; +import {createSheetEditor, type SheetDocument} from "../src/index.js"; + +test("row and column transactions preserve metadata and restore focus with undo", () => { + const initial = {title: "Sheet", columns:[{id:"a/b",label:"A"},{id:"b",label:"B"}],rows:[{id:"one",cells:{"a/b":"a",b:"b"}},{id:"two",cells:{"a/b":"c",b:"d"}}]}; + const editor=createSheetEditor(initial); + editor.dispatch({type:"selection.set",rowId:"two",columnId:"b"}); + expect(editor.dispatch({type:"row.delete",rowId:"two"}).ok).toBe(true); + expect(editor.snapshot.selection.focus).toEqual({rowId:"one",columnId:"b"}); + editor.undo(); expect(editor.snapshot.value).toEqual(initial); expect(editor.snapshot.selection.focus?.rowId).toBe("two"); + editor.dispatch({type:"column.delete",columnId:"a/b"}); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells).toEqual({b:"b"}); + editor.undo(); expect(editor.snapshot.value).toEqual(initial); + editor.dispatch({type:"column.insert",index:1,column:{id:"c",label:"C"}}); + expect((editor.snapshot.value as SheetDocument).rows[1]!.cells.c).toBe(""); + editor.undo(); expect(editor.snapshot.value).toEqual(initial); + expect(editor.dispatch({type:"column.insert",index:10,column:{id:"c",label:"C"}}).ok).toBe(false); + expect(editor.dispatch({type:"row.insert",index:0,row:{id:"bad",cells:{}}}).ok).toBe(false); +}); diff --git a/packages/json-document-markdown-react/docs/api-reference.md b/packages/json-document-markdown-react/docs/api-reference.md index e35f6e410..04940286e 100644 --- a/packages/json-document-markdown-react/docs/api-reference.md +++ b/packages/json-document-markdown-react/docs/api-reference.md @@ -6,6 +6,11 @@ Markdown 렌더링·편집 표면과 React 수명 연결의 public entrypoint입 > 이 문서는 `packages/json-document-markdown-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. +## `createMarkdownTableEditor` + +```ts +createMarkdownTableEditor(text: TextEditor, position: () => number): SheetEditor +``` ## `MarkdownComponents` ```ts diff --git a/packages/json-document-markdown-react/docs/editing.md b/packages/json-document-markdown-react/docs/editing.md index 9ce659535..d10844a2b 100644 --- a/packages/json-document-markdown-react/docs/editing.md +++ b/packages/json-document-markdown-react/docs/editing.md @@ -32,3 +32,7 @@ todo 전체를 한 번에 지우며, Undo 한 번으로 복원합니다. 기호 Markdown Web과 공용 contenteditable 투영을 사용합니다. 인용문 Enter 연결은 Markdown Web binding이 소유하며 Markdown의 `insertMarkdownParagraph`를 공용 contenteditable `insertBreak`에 연결합니다. 내용이 있으면 인용을 이어 쓰고, 빈 인용 줄에서는 일반 문단으로 나갑니다. 결과는 기존 editor에 한 번 적용하므로 Undo/Redo와 원문 선택을 유지합니다. + +## Sheet 표 편집 + +`MarkdownEditingSurface`는 최상위 GFM 표에 `SheetHand`를 연결합니다. `createMarkdownTableEditor(editor, position)`는 Sheet Intent를 Markdown table 원문 변경으로 변환하고 문서 전체 Undo/Redo를 재사용합니다. 셀 UI와 입력 상태는 Hand, 문법 처리는 Markdown, 원문과 History는 TextEditor가 소유합니다. [Sheet API](/docs/api/sheet)와 [Bear](/applications/bear)에서 확인할 수 있습니다. diff --git a/packages/json-document-markdown-react/package.json b/packages/json-document-markdown-react/package.json index 4359a918c..c38dedb4e 100644 --- a/packages/json-document-markdown-react/package.json +++ b/packages/json-document-markdown-react/package.json @@ -44,7 +44,10 @@ "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "@interactive-os/json-document-editing": "^0.1.0-rc.0", - "@interactive-os/json-document-markdown-web": "^0.1.0-rc.0" + "@interactive-os/json-document-markdown-web": "^0.1.0-rc.0", + "@interactive-os/json-document-sheet": "^0.1.0-rc.0", + "@interactive-os/json-document-markdown": "^0.1.0-rc.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@testing-library/react": "^16.3.2", @@ -57,6 +60,8 @@ "typescript": "^5.0.0", "vitest": "^4.1.7", "@interactive-os/json-document-editing": "*", - "@interactive-os/json-document-markdown-web": "*" + "@interactive-os/json-document-markdown-web": "*", + "@interactive-os/json-document-sheet": "*", + "@interactive-os/json-document-markdown": "*" } } diff --git a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx index 0566cd038..32f89bda4 100644 --- a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx +++ b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx @@ -1,4 +1,9 @@ import { useEffect, useRef, type HTMLAttributes } from "react"; +import { markdownTableBoundary, readMarkdownTable } from "@interactive-os/json-document-markdown"; +import { MarkdownRenderer } from "./MarkdownRenderer.js"; +import { createRoot } from "react-dom/client"; +import { SheetHand } from "@interactive-os/json-document-sheet"; +import { createMarkdownTableEditor } from "./markdown-table-editor.js"; import type { TextEditor } from "@interactive-os/json-document-editing"; import { createMarkdownEditingBinding } from "@interactive-os/json-document-markdown-web"; @@ -12,8 +17,22 @@ export function MarkdownEditingSurface({ editor, style, ...props }: MarkdownEdit useEffect(() => { const root = rootRef.current; if (!root) return; - const binding = createMarkdownEditingBinding({editor, root}); - return binding.bind(); + const disposals = new Set<() => void>(); + const binding = createMarkdownEditingBinding({editor, root, mountTable(element, position) { + const reactRoot = createRoot(element); + const table = createMarkdownTableEditor(editor, position); + reactRoot.render( { + const current = readMarkdownTable(editor.text, position()); + if (!current) return; + const offset = markdownTableBoundary(editor.text, current, edge); + root.focus(); editor.select({anchor: offset, focus: offset}); + }} renderCell={value => {children}}} />} />); + const dispose = () => {disposals.delete(dispose); queueMicrotask(() => reactRoot.unmount());}; + disposals.add(dispose); + return dispose; + }}); + const unbind = binding.bind(); + return () => {unbind(); disposals.forEach(dispose => dispose());}; }, [editor]); return
; } diff --git a/packages/json-document-markdown-react/src/index.ts b/packages/json-document-markdown-react/src/index.ts index cc6cf2e2f..80658f7de 100644 --- a/packages/json-document-markdown-react/src/index.ts +++ b/packages/json-document-markdown-react/src/index.ts @@ -1,3 +1,4 @@ export { MarkdownRenderer, type MarkdownComponents, type MarkdownRendererProps } from "./MarkdownRenderer.js"; export { projectStreamingMarkdown, type StreamingMarkdownProjection } from "./streaming-markdown.js"; export { MarkdownEditingSurface, type MarkdownEditingSurfaceProps } from "./MarkdownEditingSurface.js"; +export { createMarkdownTableEditor } from "./markdown-table-editor.js"; diff --git a/packages/json-document-markdown-react/src/markdown-table-editor.ts b/packages/json-document-markdown-react/src/markdown-table-editor.ts new file mode 100644 index 000000000..b153c9a28 --- /dev/null +++ b/packages/json-document-markdown-react/src/markdown-table-editor.ts @@ -0,0 +1,71 @@ +import { createSheetEditor, jsonCellText, type SheetDocument, type SheetEditor, type SheetIntent, type SheetSelection, type SheetPoint, type EditingResult, type TextEditor } from "@interactive-os/json-document-editing"; +import { readMarkdownTable, replaceMarkdownTable, type MarkdownTable } from "@interactive-os/json-document-markdown"; + +/** Adapt source table transactions to Sheet, retaining the TextEditor as the only history owner. */ +export function createMarkdownTableEditor(text: TextEditor, position: () => number): SheetEditor { + let observed = ""; + let sheet: SheetEditor; + let revision = 0; + let projectedSelection: SheetSelection | undefined; + const listeners = new Set[0]>(); + let unsubscribe: (() => void) | undefined; + const read = () => { + if (sheet && observed === text.text) return; + const selected = projectedSelection ?? sheet?.snapshot.selection; + projectedSelection = undefined; + observed = text.text; + const table = readMarkdownTable(observed, position()); + const width = table?.align.length || table?.rows[0]?.length || 0; + const columns = Array.from({length: width}, (_, i) => ({id: `c${i}`, label: String.fromCharCode(65 + i)})); + sheet = createSheetEditor({columns, rows: (table?.rows ?? []).map((row, i) => ({id: `r${i}`, cells: Object.fromEntries(columns.map((column, j) => [column.id, row[j] ?? ""]))}))}, selected ? {selection: selected} : {}); + }; + const snapshot = () => {read(); return {...sheet.snapshot, revision, canUndo: text.snapshot.canUndo, canRedo: text.snapshot.canRedo};}; + const publish = () => {revision++; const next = snapshot(); listeners.forEach(listener => listener(next));}; + const mutate = (intent: SheetIntent): EditingResult => { + read(); + const table = readMarkdownTable(text.text, position()); + if (!table) return {ok: false, code: "table.unavailable"}; + if ((intent.type === "row.insert" && intent.index === 0) || (intent.type === "row.delete" && intent.rowId === "r0") || (intent.type === "column.delete" && table.align.length <= 1)) return {ok: false, code: "table.header-required"}; + const result = sheet.dispatch(intent); + if (!result.ok) return result; + if (intent.type === "selection.set" || intent.type === "selection.select-all") {publish(); return {ok: true, snapshot: snapshot()};} + return commit(table); + }; + const commit = (table: MarkdownTable): EditingResult => { + const value = sheet.snapshot.value as SheetDocument; + const rows = value.rows.map(row => value.columns.map(column => jsonCellText(row.cells[column.id]))); + const align = value.columns.map(column => table.align[Number(column.id.slice(1))] ?? null); + const next = replaceMarkdownTable(text.text, table, rows, align); + const point = (point: SheetPoint): SheetPoint => ({rowId: `r${value.rows.findIndex(row => row.id === point.rowId)}`, columnId: `c${value.columns.findIndex(column => column.id === point.columnId)}`}); + const selection = sheet.snapshot.selection; + projectedSelection = {...selection, anchor: selection.anchor ? point(selection.anchor) : null, focus: selection.focus ? point(selection.focus) : null, + ranges: selection.ranges.map(range => ({anchor: point(range.anchor), focus: point(range.focus)}))}; + const committed = text.replace(next, {anchor: table.from, focus: table.from}); + if (!committed.ok) {observed = ""; read(); return committed;} + // A selection-only sheet engine is rebuilt from source after every document transaction. + observed = ""; publish(); + return {ok: true, snapshot: snapshot()}; + }; + const history = (action: "undo" | "redo"): EditingResult => { + const result = text[action](); if (!result.ok) return result; + publish(); return {ok: true, snapshot: snapshot()}; + }; + return { + get snapshot() {return snapshot();}, + get selectedCells() {read(); return sheet.selectedCells;}, + selectedCellsIn(topology) {read(); return sheet.selectedCellsIn(topology);}, + dispatch: mutate, + copy(topology) {read(); return sheet.copy(topology);}, + cut(topology) { + read(); const table = readMarkdownTable(text.text, position()); if (!table) return null; + const cut = sheet.cut(topology); if (!cut) return null; + return {clipboard: cut.clipboard, result: cut.result.ok ? commit(table) : cut.result}; + }, + undo: () => history("undo"), redo: () => history("redo"), + subscribe(listener) { + listeners.add(listener); + unsubscribe ??= text.subscribe(publish); + return () => {listeners.delete(listener); if (!listeners.size) {unsubscribe?.(); unsubscribe = undefined;}}; + }, + }; +} diff --git a/packages/json-document-markdown-react/tests/markdown-table-editor.test.ts b/packages/json-document-markdown-react/tests/markdown-table-editor.test.ts new file mode 100644 index 000000000..fb72d7e0e --- /dev/null +++ b/packages/json-document-markdown-react/tests/markdown-table-editor.test.ts @@ -0,0 +1,37 @@ +import {expect, test} from "vitest"; +import {createJSONDocument} from "@interactive-os/json-document"; +import {createTextEditor, type SheetDocument} from "@interactive-os/json-document-editing"; +import {createMarkdownTableEditor} from "../src/markdown-table-editor.js"; +test("cell and structure edits use whole-document history and never store UI labels", () => { + const original = "| A | B |\n| --- | --- |\n| x | y |\n\nend"; + const text = createTextEditor(createJSONDocument(original)); + const sheet = createMarkdownTableEditor(text, () => 0); + const unsubscribe = sheet.subscribe(() => {}); + expect(sheet.dispatch({type:"cell.commit",rowId:"r1",columnId:"c0",value:"updated"}).ok).toBe(true); + expect(text.text).toContain("| updated | y |"); + sheet.undo(); expect(text.text).toBe(original); + sheet.redo(); expect(text.text).toContain("updated"); + sheet.dispatch({type:"column.insert",index:1,column:{id:"new",label:"C"}}); + expect((sheet.snapshot.value as SheetDocument).columns).toHaveLength(3); + sheet.undo(); expect((sheet.snapshot.value as SheetDocument).columns).toHaveLength(2); + expect(sheet.dispatch({type:"row.delete",rowId:"r0"}).ok).toBe(false); + text.replace(text.text+"!",{anchor:0,focus:0}); + sheet.undo(); expect(text.text.endsWith("end")).toBe(true); + unsubscribe(); +}); + +test("cut clears only the primary range and paste retains its rectangle", () => { + const text=createTextEditor(createJSONDocument("| A | B |\n| --- | --- |\n| x | y |\n| z | w |")); + const sheet=createMarkdownTableEditor(text,()=>0); + sheet.dispatch({type:"selection.set",rowId:"r1",columnId:"c0"}); + sheet.dispatch({type:"selection.set",rowId:"r2",columnId:"c1",mode:"toggle"}); + const cut=sheet.cut()!; + expect(cut.clipboard.text).toBe("w"); + expect((sheet.snapshot.value as SheetDocument).rows[1]!.cells.c0).toBe("x"); + expect((sheet.snapshot.value as SheetDocument).rows[2]!.cells.c1).toBe(""); + sheet.dispatch({type:"selection.set",rowId:"r1",columnId:"c0"}); + sheet.dispatch({type:"clipboard.paste",clipboard:{type:"application/vnd.interactive-os.sheet+json",cells:[["1","2"]],text:"1\t2"}}); + expect(sheet.selectedCells).toHaveLength(2); + sheet.dispatch({type:"column.delete",columnId:"c0"}); + expect(sheet.snapshot.selection.focus?.columnId).toBe("c0"); +}); diff --git a/packages/json-document-markdown-react/tsconfig.json b/packages/json-document-markdown-react/tsconfig.json index b47894ddc..3516acae9 100644 --- a/packages/json-document-markdown-react/tsconfig.json +++ b/packages/json-document-markdown-react/tsconfig.json @@ -15,6 +15,12 @@ }, { "path": "../json-document-markdown-web" + }, + { + "path": "../json-document-sheet" + }, + { + "path": "../json-document-markdown" } ] } diff --git a/packages/json-document-markdown-web/docs/api-reference.md b/packages/json-document-markdown-web/docs/api-reference.md index 754800412..fe679435c 100644 --- a/packages/json-document-markdown-web/docs/api-reference.md +++ b/packages/json-document-markdown-web/docs/api-reference.md @@ -14,7 +14,7 @@ createMarkdownDOMAdapter(options?: MarkdownDOMOptions): TextDOMAdapter ## `createMarkdownEditingBinding` ```ts -createMarkdownEditingBinding({ editor, root }: MarkdownEditingBindingOptions): ContentEditableBinding +createMarkdownEditingBinding({ editor, root, mountTable }: MarkdownEditingBindingOptions): ContentEditableBinding ``` ## `MarkdownDOMOptions` @@ -22,6 +22,8 @@ createMarkdownEditingBinding({ editor, root }: MarkdownEditingBindingOptions): C interface MarkdownDOMOptions { /** Enables task controls using the existing source editor and its history. */ readonly editor?: TextEditor; + /** Mount a table Hand in a source-excluded island. Return its disposal callback. */ + readonly mountTable?: (element: HTMLElement, position: () => number) => () => void; } ``` ## `MarkdownEditingBindingOptions` @@ -30,5 +32,6 @@ interface MarkdownDOMOptions { interface MarkdownEditingBindingOptions { readonly editor: TextEditor; readonly root: HTMLElement; + readonly mountTable?: MarkdownDOMOptions["mountTable"]; } ``` diff --git a/packages/json-document-markdown-web/src/editing-binding.ts b/packages/json-document-markdown-web/src/editing-binding.ts index 199e36c38..870be7c99 100644 --- a/packages/json-document-markdown-web/src/editing-binding.ts +++ b/packages/json-document-markdown-web/src/editing-binding.ts @@ -1,18 +1,19 @@ import { createContentEditableBinding, type ContentEditableBinding } from "@interactive-os/json-document-contenteditable"; import type { TextEditor } from "@interactive-os/json-document-editing"; import { indentMarkdownList, insertMarkdownParagraph } from "@interactive-os/json-document-markdown"; -import { createMarkdownDOMAdapter } from "./markdown-dom.js"; +import { createMarkdownDOMAdapter, type MarkdownDOMOptions } from "./markdown-dom.js"; export interface MarkdownEditingBindingOptions { readonly editor: TextEditor; readonly root: HTMLElement; + readonly mountTable?: MarkdownDOMOptions["mountTable"]; } /** Connect Markdown DOM, syntax-owned Enter, and the editor's existing history. */ -export function createMarkdownEditingBinding({editor, root}: MarkdownEditingBindingOptions): ContentEditableBinding { +export function createMarkdownEditingBinding({editor, root, mountTable}: MarkdownEditingBindingOptions): ContentEditableBinding { return createContentEditableBinding({ document: editor.document, pointer: editor.pointer, editor, root, - dom: createMarkdownDOMAdapter({editor}), + dom: createMarkdownDOMAdapter({editor, ...(mountTable ? {mountTable} : {})}), indent(editor, direction) { const next = indentMarkdownList(editor.text, editor.snapshot.selection, direction); return next ? editor.replace(next.value, next.selection) : null; diff --git a/packages/json-document-markdown-web/src/markdown-dom.ts b/packages/json-document-markdown-web/src/markdown-dom.ts index ddf223be2..429aa5320 100644 --- a/packages/json-document-markdown-web/src/markdown-dom.ts +++ b/packages/json-document-markdown-web/src/markdown-dom.ts @@ -13,6 +13,7 @@ interface RenderedRun { readonly element: HTMLElement; text?: Text; children: RenderedRun[]; + dispose?: () => void; } interface Surface { readonly parser: MarkdownParser; @@ -26,6 +27,8 @@ interface Surface { export interface MarkdownDOMOptions { /** Enables task controls using the existing source editor and its history. */ readonly editor?: TextEditor; + /** Mount a table Hand in a source-excluded island. Return its disposal callback. */ + readonly mountTable?: (element: HTMLElement, position: () => number) => () => void; } /** Source-preserving CommonMark/GFM DOM; all editing still uses source coordinates. */ @@ -67,7 +70,7 @@ export function createMarkdownDOMAdapter(options: MarkdownDOMOptions = {}): Text const edit = diffText(previous, source)!; surface.parser.update(edit.from, edit.to, edit.insert); } - surface.runs = reconcileRuns(root, surface.runs, sourceRuns(surface.parser.projection), (run, input, action) => { + surface.runs = reconcileRuns(root, surface.runs, sourceRuns(surface.parser.projection, !!options.mountTable), (run, input, action) => { const editor = options.editor; const task = run.task!; const current = surface!.parser.projection.source; @@ -81,7 +84,7 @@ export function createMarkdownDOMAdapter(options: MarkdownDOMOptions = {}): Text : editor[action](); if (!result.ok) input.checked = task.checked; if (input.isConnected) input.focus({preventScroll:true}); - }, !!options.editor && root.getAttribute("contenteditable") !== "false"); + }, !!options.editor && root.getAttribute("contenteditable") !== "false", options.mountTable); renderTextCaretBoundary(root, source); surface.observer.takeRecords(); surface.dirty = false; @@ -108,7 +111,7 @@ export function createMarkdownDOMAdapter(options: MarkdownDOMOptions = {}): Text /** Reuse unchanged prefixes/suffixes and preserve text-node identity while typing. */ function reconcileRuns(parent: HTMLElement, previous: RenderedRun[], next: ReadonlyArray, - onTaskChange: (run: SourceRun, input: HTMLInputElement, action: TaskAction) => void, tasksEnabled: boolean): RenderedRun[] { + onTaskChange: (run: SourceRun, input: HTMLInputElement, action: TaskAction) => void, tasksEnabled: boolean, mountTable?: MarkdownDOMOptions["mountTable"]): RenderedRun[] { const matches = (entry: RenderedRun, run: SourceRun) => entry.run.kind === run.kind && entry.run.value === run.value; let prefix = 0; while (prefix < previous.length && prefix < next.length && matches(previous[prefix]!, next[prefix]!)) prefix++; @@ -130,7 +133,9 @@ function reconcileRuns(parent: HTMLElement, previous: RenderedRun[], next: Reado entry.text ??= parent.ownerDocument.createTextNode(run.value); if (entry.text.data !== run.value) entry.text.data = run.value; if (element.childNodes.length !== 1 || element.firstChild !== entry.text) element.replaceChildren(entry.text); - } else entry.children = reconcileRuns(element, entry.children, run.children ?? [], onTaskChange, tasksEnabled); + } else if (run.table && mountTable) { + entry.dispose ??= mountTable(element, () => entry!.run.table!.from); + } else entry.children = reconcileRuns(element, entry.children, run.children ?? [], onTaskChange, tasksEnabled, mountTable); if (run.task) { const input = element as HTMLInputElement; input.checked = run.task.checked; @@ -149,6 +154,8 @@ function reconcileRuns(parent: HTMLElement, previous: RenderedRun[], next: Reado entry.run = run; result.push(entry); } + const dispose = (entry: RenderedRun) => {entry.dispose?.(); entry.children.forEach(dispose);}; + for (const entry of previous) if (!result.includes(entry)) dispose(entry); while (cursor) { const nextSibling = cursor.nextSibling; cursor.remove(); cursor = nextSibling; } return result; } diff --git a/packages/json-document-markdown-web/src/markdown-editor.css b/packages/json-document-markdown-web/src/markdown-editor.css index 2ff0483e9..e8662a431 100644 --- a/packages/json-document-markdown-web/src/markdown-editor.css +++ b/packages/json-document-markdown-web/src/markdown-editor.css @@ -137,3 +137,6 @@ width: 100%; border-top: 1px solid var(--markdown-border, #d8d1c5); } + +[data-markdown-kind="table"][data-markdown-sheet] { display: inline-block; width: 100%; white-space: normal; font-family: inherit; } +[data-markdown-sheet] [data-text-decoration] { display: block; } diff --git a/packages/json-document-markdown-web/src/source-runs.ts b/packages/json-document-markdown-web/src/source-runs.ts index 77237a879..13fc8300d 100644 --- a/packages/json-document-markdown-web/src/source-runs.ts +++ b/packages/json-document-markdown-web/src/source-runs.ts @@ -2,13 +2,14 @@ import type { MarkdownNode, MarkdownNodeKind, MarkdownProjection, MarkdownMarker interface SourceRange { readonly from: number; readonly to: number } export interface SourceRun extends SourceRange { - readonly kind: MarkdownNodeKind | "source" | "delimiter" | "imagePreview" | "taskControl"; + readonly kind: MarkdownNodeKind | "source" | "delimiter" | "imagePreview" | "taskControl" | "tableControl"; readonly tag: string; readonly attributes: Readonly>; readonly value?: string; readonly children?: ReadonlyArray; readonly owner?: SourceRange; readonly conceal?: boolean | "always"; + readonly table?: SourceRange; readonly task?: { readonly from: number; readonly checked: boolean }; readonly projection?: { readonly to: number; readonly following?: number; readonly atomic?: boolean }; } @@ -33,7 +34,7 @@ function safeURL(value: string | undefined, image = false): string | undefined { } /** A source-complete tree: visual decoration never adds text to the document. */ -export function sourceRuns(projection: MarkdownProjection): SourceRun[] { +export function sourceRuns(projection: MarkdownProjection, editableTables = false): SourceRun[] { const { source } = projection; const definitions = new Map(); const collect = (nodes: ReadonlyArray) => { @@ -142,6 +143,14 @@ export function sourceRuns(projection: MarkdownProjection): SourceRun[] { return result; }; const visit = (node: MarkdownNode, parent?: MarkdownNode, index = 0, table?: MarkdownNode): SourceRun => { + if (editableTables && node.kind === "table" && !parent) return { + kind: "table", tag: "span", from: node.from, to: node.to, attributes: {"data-markdown-kind": "table", "data-markdown-sheet": ""}, + projection: {to: node.to, atomic: true}, children: [ + {...plain(node.from, node.to), attributes: {hidden: "", "data-text-projection-source": ""}}, + {kind: "tableControl", tag: "span", from: node.to, to: node.to, table: node, + attributes: {"data-text-decoration": "", contenteditable: "false"}}, + ], + }; const attributes: Record = { "data-markdown-kind": node.kind }; if (node.depth) { attributes.role = "heading"; attributes["aria-level"] = String(node.depth); attributes["data-depth"] = String(node.depth); } if (node.kind === "list") attributes.role = "list"; diff --git a/packages/json-document-markdown/docs/api-reference.md b/packages/json-document-markdown/docs/api-reference.md index d08f91c15..6432de97c 100644 --- a/packages/json-document-markdown/docs/api-reference.md +++ b/packages/json-document-markdown/docs/api-reference.md @@ -109,6 +109,21 @@ interface MarkdownStrongSpan { readonly contentTo: number; } ``` +## `MarkdownTable` + +```ts +interface MarkdownTable { + readonly from: number; + readonly to: number; + readonly rows: ReadonlyArray>; + readonly align: ReadonlyArray<"left" | "right" | "center" | null>; +} +``` +## `markdownTableBoundary` + +```ts +markdownTableBoundary(source: string, table: MarkdownTable, edge: "before" | "after"): number +``` ## `MarkdownUpdate` ```ts @@ -122,6 +137,16 @@ interface MarkdownUpdate { ```ts projectMarkdown(source: string): MarkdownProjection ``` +## `readMarkdownTable` + +```ts +readMarkdownTable(source: string, at: number): MarkdownTable | null +``` +## `replaceMarkdownTable` + +```ts +replaceMarkdownTable(source: string, table: MarkdownTable, rows: ReadonlyArray>, align?: readonly ("left" | "right" | "center")[]): string +``` ## `setMarkdownTaskChecked` ```ts diff --git a/packages/json-document-markdown/src/index.ts b/packages/json-document-markdown/src/index.ts index 0a00c8dca..e56d3064c 100644 --- a/packages/json-document-markdown/src/index.ts +++ b/packages/json-document-markdown/src/index.ts @@ -6,3 +6,4 @@ export { setMarkdownTaskChecked } from "./tasks.js"; export { insertMarkdownParagraph } from "./paragraph.js"; export { indentMarkdownList } from "./list-editing.js"; export type { MarkdownSourceEdit } from "./source-edit.js"; +export { markdownTableBoundary, readMarkdownTable, replaceMarkdownTable, type MarkdownTable } from "./table-editing.js"; diff --git a/packages/json-document-markdown/src/table-editing.ts b/packages/json-document-markdown/src/table-editing.ts new file mode 100644 index 000000000..14c64f660 --- /dev/null +++ b/packages/json-document-markdown/src/table-editing.ts @@ -0,0 +1,35 @@ +import { projectMarkdown } from "./projection.js"; + +/** Markdown source cells, including the header as row zero. */ +export interface MarkdownTable { + readonly from: number; + readonly to: number; + readonly rows: ReadonlyArray>; + readonly align: ReadonlyArray<"left" | "right" | "center" | null>; +} + +/** Locate a top-level GFM table by source coordinate; cell values retain inline Markdown. */ +export function readMarkdownTable(source: string, at: number): MarkdownTable | null { + const node = projectMarkdown(source).nodes.find(node => node.kind === "table" && node.from <= at && at <= node.to); + if (!node) return null; + return {from: node.from, to: node.to, align: node.align ?? [], rows: (node.children ?? []).map(row => + (row.children ?? []).map(cell => cell.children?.length ? source.slice(cell.children[0]!.from, cell.children.at(-1)!.to) : ""))}; +} + +/** Replace only the selected table. New lines cannot escape into surrounding blocks. */ +export function replaceMarkdownTable(source: string, table: MarkdownTable, rows: ReadonlyArray>, align = table.align): string { + const width = rows[0]?.length ?? 0; + if (!width || !rows.length || rows.some(row => row.length !== width)) throw new RangeError("Markdown tables need a nonempty rectangular header"); + const cell = (value: string) => value.replace(/[\r\n]+/g, " ").replace(/(\\*)\|/g, (match, escapes: string) => escapes.length % 2 ? match : `${escapes}\\|`); + const line = (row: ReadonlyArray) => `| ${row.map(cell).join(" | ")} |`; + const ending = source.slice(table.from, table.to).includes("\r\n") ? "\r\n" : "\n"; + const separator = rows[0]!.map((_, i) => align[i] === "center" ? ":---:" : align[i] === "right" ? "---:" : align[i] === "left" ? ":---" : "---"); + const replacement = [line(rows[0]!), line(separator), ...rows.slice(1).map(line)].join(ending); + return source.slice(0, table.from) + replacement + source.slice(table.to); +} + +/** Source boundary in the adjacent block, beyond the blank line separating a table. */ +export function markdownTableBoundary(source: string, table: MarkdownTable, edge: "before" | "after"): number { + return edge === "before" ? Math.max(0, table.from - 1) + : Math.min(source.length, table.to + (/^\r?\n(?:\r?\n)?/.exec(source.slice(table.to))?.[0].length ?? 0)); +} diff --git a/packages/json-document-markdown/tests/table-editing.test.ts b/packages/json-document-markdown/tests/table-editing.test.ts new file mode 100644 index 000000000..b0eb02ae6 --- /dev/null +++ b/packages/json-document-markdown/tests/table-editing.test.ts @@ -0,0 +1,11 @@ +import {expect, test} from "vitest"; +import {readMarkdownTable, replaceMarkdownTable} from "../src/index.js"; +test("table edits preserve surrounding source, alignment, CRLF and escaped pipes", () => { + const source = "before\r\n\r\n| A | B |\r\n| :--- | ---: |\r\n| x | y |\r\n\r\nafter"; + const table = readMarkdownTable(source, 12)!; + expect(table.align).toEqual(["left","right"]); + const next = replaceMarkdownTable(source,table,[["A","B"],["a|b","line\nbreak"]]); + expect(next).toBe("before\r\n\r\n| A | B |\r\n| :--- | ---: |\r\n| a\\|b | line break |\r\n\r\nafter"); + expect(readMarkdownTable(next,table.from)?.rows[1]).toEqual(["a\\|b","line break"]); + expect(() => replaceMarkdownTable(source,table,[])).toThrow(); +}); diff --git a/packages/json-document-sheet/LICENSE b/packages/json-document-sheet/LICENSE new file mode 100644 index 000000000..063830729 --- /dev/null +++ b/packages/json-document-sheet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 interactive-os + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/json-document-sheet/README.md b/packages/json-document-sheet/README.md new file mode 100644 index 000000000..adf31733f --- /dev/null +++ b/packages/json-document-sheet/README.md @@ -0,0 +1,3 @@ +# Sheet Hand + +`SheetHand` composes the canonical Sheet editor, selection, keyboard, clipboard and UI primitives. Supply a `SheetEditor`; its owner retains data and history. diff --git a/packages/json-document-sheet/docs/api-reference.md b/packages/json-document-sheet/docs/api-reference.md new file mode 100644 index 000000000..4aedeaeba --- /dev/null +++ b/packages/json-document-sheet/docs/api-reference.md @@ -0,0 +1,25 @@ +# @interactive-os/json-document-sheet API + +**탐색 분류:** Hands + +Sheet와 Markdown 표의 셀 선택·편집·구조 조작 UI 조합의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 저장소의 아키텍처 등록에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-sheet/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `SheetHand` + +```ts +SheetHand({ editor, label, headerRow, renderCell, onExit }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` +## `SheetHandProps` + +```ts +interface SheetHandProps { + readonly editor: SheetEditor; + readonly label?: string; + /** Markdown tables retain a mandatory header row and at least one column. */ + readonly headerRow?: boolean; + readonly onExit?: (edge: "before" | "after") => void; + readonly renderCell?: (value: string) => ReactNode; +} +``` diff --git a/packages/json-document-sheet/docs/editing.md b/packages/json-document-sheet/docs/editing.md new file mode 100644 index 000000000..dd16f4865 --- /dev/null +++ b/packages/json-document-sheet/docs/editing.md @@ -0,0 +1,21 @@ +# Sheet Hand + +`@interactive-os/json-document-sheet`는 Hands 위치에서 셀 선택·편집 모드·키보드 이동·클립보드와 행/열 조작을 조합합니다. 문서, selection, History는 `SheetEditor`가 소유합니다. + +```tsx +import { createSheetEditor } from '@interactive-os/json-document-editing'; +import { SheetHand } from '@interactive-os/json-document-sheet'; +const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: '1', cells: {a: 'Hello'}}]}); +; +``` + +[Sheet Usage](/demo/sheet)와 [Bear](/applications/bear)는 같은 Hand를 소비합니다. Bear의 Markdown React adapter는 각 변경을 원문 교체로 번역하고 TextEditor의 Undo/Redo를 사용합니다. + +- 클릭: 셀 선택. Shift+클릭/방향키: 범위 확장. Mod+클릭: 불연속 범위. +- 방향키: 셀 이동. Tab/Shift+Tab: 다음/이전 셀. 표 경계에서는 기본 Tab 흐름. +- Enter/F2/더블클릭: 편집. 입력 중 방향키는 문자열 안에서 이동. +- 편집 중 Enter: 확정 후 아래 셀. Escape: 취소. Tab: 확정 후 다음 셀. +- 복사/잘라내기/붙여넣기: Sheet의 정본 TSV 및 structured clipboard 계약. +- 행/열 추가·삭제: 단일 History transaction. `headerRow`에서는 첫 행과 마지막 열 삭제를 막습니다. + +현재 Markdown 연결은 최상위 GFM 표에 적용됩니다. 셀의 inline Markdown을 원문으로 편집하며, 줄바꿈은 공백으로, 구분자 pipe는 escape하여 표 구조를 유지합니다. 수식 계산이나 파일 가져오기는 이 Hand의 기능이 아닙니다. diff --git a/packages/json-document-sheet/package.json b/packages/json-document-sheet/package.json new file mode 100644 index 000000000..e24aef2a9 --- /dev/null +++ b/packages/json-document-sheet/package.json @@ -0,0 +1,64 @@ +{ + "name": "@interactive-os/json-document-sheet", + "version": "0.1.0-rc.0", + "description": "Canonical React Sheet Hand for cell and table editing.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/json-document-sheet" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "LICENSE", + "docs" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -b tsconfig.json", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-react": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "@vitejs/plugin-react": "^5.2.0" + } +} diff --git a/packages/json-document-sheet/src/index.ts b/packages/json-document-sheet/src/index.ts new file mode 100644 index 000000000..dfe4ff79f --- /dev/null +++ b/packages/json-document-sheet/src/index.ts @@ -0,0 +1 @@ +export { SheetHand, type SheetHandProps } from "./sheet-hand.js"; diff --git a/packages/json-document-sheet/src/sheet-hand.tsx b/packages/json-document-sheet/src/sheet-hand.tsx new file mode 100644 index 000000000..adc040f6f --- /dev/null +++ b/packages/json-document-sheet/src/sheet-hand.tsx @@ -0,0 +1,104 @@ +import { useMemo, useRef, useState, type ReactNode, type KeyboardEvent } from "react"; +import { jsonCellText, type SheetDocument, type SheetEditor, type GridPoint } from "@interactive-os/json-document-editing"; +import { editingItemProps, useEditingSnapshot, useGridEditing } from "@interactive-os/json-document-react"; +import { editingCommandFromWebKeyboardStroke } from "@interactive-os/json-document-affordance"; +import { createWebClipboardSurface, findWebGridCell, gridBoundary, moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, webGridCellAddressProps } from "@interactive-os/json-document-web"; +import { Command, ContextualControls, Toolbar, GridCell } from "@interactive-os/json-document-ui-primitives-react"; + +export interface SheetHandProps { + readonly editor: SheetEditor; + readonly label?: string; + /** Markdown tables retain a mandatory header row and at least one column. */ + readonly headerRow?: boolean; + readonly onExit?: (edge: "before" | "after") => void; + readonly renderCell?: (value: string) => ReactNode; +} + +/** Shared cell selection, edit mode, clipboard and structural controls. Data/history stay with editor. */ +export function SheetHand({editor, label = "표 편집", headerRow = false, renderCell, onExit}: SheetHandProps) { + const snapshot = useEditingSnapshot(editor); + const sheet = snapshot.value as SheetDocument; + const surface = useRef(null); + const [draft, setDraft] = useState<{point: GridPoint; value: string} | null>(null); + const draftRef = useRef(draft); draftRef.current = draft; + const [message, setMessage] = useState(""); + const focus = snapshot.selection.focus; + const topology = {rowIds: sheet.rows.map(row => row.id), columnIds: sheet.columns.map(column => column.id)}; + const report = (result: {ok: boolean; code?: string}) => {setMessage(result.ok ? "" : result.code ?? "변경할 수 없습니다"); return result.ok;}; + const focusCell = (point: GridPoint) => findWebGridCell(surface.current, point)?.focus(); + const select = (point: GridPoint, mode: "replace" | "extend" | "toggle" = "replace") => { + editor.dispatch({type: "selection.set", ...point, mode}); focusCell(point); + }; + const editing = useGridEditing({source: editor, selectedPoints: editor.selectedCells, focusPoint: focus, onSelect: select, + keyboard: {resolve: editingCommandFromWebKeyboardStroke, focusPoint: () => editor.snapshot.selection.focus ?? undefined, + neighbor: (point, command) => command.type === "move" ? moveGridPoint(topology, point, command.direction) : gridBoundary(topology, point, command.edge), + onDelete: () => report(editor.dispatch({type: "selection.fill", value: ""})), onUndo: () => report(editor.undo()), onRedo: () => report(editor.redo()), afterMove: focusCell}}); + const clipboard = useMemo(() => createWebClipboardSurface({codec: sheetClipboardCodec, read: () => editor.copy(), + cut: () => editor.cut()?.result ?? {ok: false, code: "selection.empty"}, paste: clipboard => editor.dispatch({type: "clipboard.paste", clipboard}), onResult: result => {if (!result.ok) setMessage(result.code);}}), [editor]); + const finish = () => { + const current = draftRef.current; + if (!current) return true; + draftRef.current = null; setDraft(null); + return report(editor.dispatch({type: "cell.commit", ...current.point, value: current.value})); + }; + const tab = (point: GridPoint, backward: boolean) => { + const index = topology.rowIds.indexOf(point.rowId) * topology.columnIds.length + topology.columnIds.indexOf(point.columnId) + (backward ? -1 : 1); + if (index < 0 || index >= sheet.rows.length * sheet.columns.length) {if (!onExit) return false; onExit(backward ? "before" : "after"); return true;} + select({rowId: topology.rowIds[Math.floor(index / sheet.columns.length)]!, columnId: topology.columnIds[index % sheet.columns.length]!}); return true; + }; + const keyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (draftRef.current) return; + if (!focus || event.target instanceof HTMLButtonElement) return; + if (event.key === "Tab") {if (tab(focus, event.shiftKey)) event.preventDefault(); return;} + if (event.key === "Enter" || event.key === "F2" || (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey)) { + event.preventDefault(); const cell = sheet.rows.find(row => row.id === focus.rowId)?.cells[focus.columnId]; + setDraft({point: focus, value: event.key.length === 1 ? event.key : jsonCellText(cell)}); return; + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {event.preventDefault(); editor.dispatch({type: "selection.select-all"}); return;} + editing.getKeyDownHandler()(event); + }; + const rowIndex = sheet.rows.findIndex(row => row.id === focus?.rowId); + const columnIndex = sheet.columns.findIndex(column => column.id === focus?.columnId); + const nextId = (ids: readonly string[], prefix: string) => {let i = 1; while (ids.includes(`${prefix}-${i}`)) i++; return `${prefix}-${i}`;}; + return + {context =>
event.stopPropagation()} onInput={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}> + + report(editor.dispatch({type: "row.insert", index: Math.max(headerRow ? 1 : 0, rowIndex + 1), row: {id: nextId(topology.rowIds, "row"), cells: Object.fromEntries(sheet.columns.map(column => [column.id, ""]))}}))}>행 추가 + report(editor.dispatch({type: "column.insert", index: columnIndex + 1, column: {id: nextId(topology.columnIds, "column"), label: String.fromCharCode(65 + sheet.columns.length)}}))}>열 추가 + focus && report(editor.dispatch({type: "row.delete", rowId: focus.rowId}))}>행 삭제 + focus && report(editor.dispatch({type: "column.delete", columnId: focus.columnId}))}>열 삭제 + report(editor.undo())}>실행 취소 + report(editor.redo())}>다시 실행 + +
+ + )} + {sheet.rows.map((row, index) => {sheet.columns.map(column => { + const point = {rowId: row.id, columnId: column.id}; const item = editing.getCell(point); + const active = draft?.point.rowId === row.id && draft.point.columnId === column.id; + return setDraft({point, value: jsonCellText(row.cells[column.id])})}> + {active ? event.currentTarget.select()} onPointerDown={event => event.stopPropagation()} + onChange={event => setDraft({point, value: event.target.value})} onBlur={finish} + onKeyDown={event => { + event.stopPropagation(); if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Escape") {event.preventDefault(); draftRef.current = null; setDraft(null); focusCell(point);} + else if (event.key === "Enter" || event.key === "Tab") { + if (event.key === "Enter") event.preventDefault(); + if (!finish()) return; + if (event.key === "Tab") {if (tab(point, event.shiftKey)) event.preventDefault();} + else {const next = moveGridPoint(topology, point, event.shiftKey ? "up" : "down"); select(next ?? point);} + } + }} /> : (renderCell ? renderCell(jsonCellText(row.cells[column.id])) : jsonCellText(row.cells[column.id])) || } + ; + })})} +
{sheet.columns.map(column => {column.label}
{headerRow && index === 0 ? "제목" : index + (headerRow ? 0 : 1)}
+
+ {message && {message}} +
} +
; +} diff --git a/packages/json-document-sheet/tests/sheet-hand.test.tsx b/packages/json-document-sheet/tests/sheet-hand.test.tsx new file mode 100644 index 000000000..0f1368fea --- /dev/null +++ b/packages/json-document-sheet/tests/sheet-hand.test.tsx @@ -0,0 +1,26 @@ +import { afterEach, expect, test } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createSheetEditor, type SheetDocument } from "@interactive-os/json-document-editing"; +import { SheetHand } from "../src/index.js"; +afterEach(cleanup); +test("edit commits once, Escape cancels, structural commands undo", () => { + const editor = createSheetEditor({columns:[{id:"a",label:"A"}],rows:[{id:"1",cells:{a:"one"}},{id:"2",cells:{a:"two"}}]}); + render(); + fireEvent.doubleClick(screen.getByText("one")); + fireEvent.change(screen.getByRole("textbox"), {target:{value:"changed"}}); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("one"); + fireEvent.keyDown(screen.getByRole("textbox"), {key:"Escape"}); + expect(screen.queryByRole("textbox")).toBeNull(); + fireEvent.doubleClick(screen.getByText("one")); + fireEvent.change(screen.getByRole("textbox"), {target:{value:"changed"}}); + fireEvent.keyDown(screen.getByRole("textbox"), {key:"Enter", isComposing:true, keyCode:229}); + expect(screen.queryByRole("textbox")).not.toBeNull(); + fireEvent.keyDown(screen.getByRole("textbox"), {key:"Enter"}); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("changed"); + fireEvent.click(screen.getByRole("button", {name:"실행 취소"})); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("one"); + fireEvent.click(screen.getByRole("button", {name:"열 추가"})); + expect((editor.snapshot.value as SheetDocument).columns).toHaveLength(2); + fireEvent.click(screen.getByRole("button", {name:"실행 취소"})); + expect((editor.snapshot.value as SheetDocument).columns).toHaveLength(1); +}); diff --git a/packages/json-document-sheet/tsconfig.json b/packages/json-document-sheet/tsconfig.json new file mode 100644 index 000000000..4ecea65f0 --- /dev/null +++ b/packages/json-document-sheet/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig/library-react.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [ + { + "path": "../json-document-affordance" + }, + { + "path": "../json-document-editing" + }, + { + "path": "../json-document-react" + }, + { + "path": "../json-document-ui-primitives-react" + }, + { + "path": "../json-document-web" + } + ], + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/json-document-sheet/tsconfig.test.json b/packages/json-document-sheet/tsconfig.test.json new file mode 100644 index 000000000..7850a9a27 --- /dev/null +++ b/packages/json-document-sheet/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig/test-dom.json"], + "compilerOptions": { "rootDir": "../.." }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx", "vitest.config.ts"] +} diff --git a/packages/json-document-sheet/vitest.config.ts b/packages/json-document-sheet/vitest.config.ts new file mode 100644 index 000000000..34c5ea5ce --- /dev/null +++ b/packages/json-document-sheet/vitest.config.ts @@ -0,0 +1,7 @@ +import react from "@vitejs/plugin-react"; +import { defineDOMReactProject } from "../../test/vitest.shared.js"; + +export default defineDOMReactProject("json-document-sheet", { + plugins: [react()], + resolve: { dedupe: ["react", "react-dom"] }, +}); diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index cb99fa126..ae9e41c8a 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -7,6 +7,7 @@ export interface SourceAlias { export function jsonDocumentSourceAliases(): SourceAlias[] { return [ + { find: "@interactive-os/json-document-sheet", replacement: sourceFile("packages/json-document-sheet/src/index.ts") }, { find: "@interactive-os/json-document-web/interaction-recording", replacement: sourceFile("packages/json-document-web/src/interaction-recording-index.ts") }, { find: "@interactive-os/json-document-contenteditable/text-projection.css", replacement: sourceFile("packages/json-document-contenteditable/src/dom/text-projection.css") }, { find: "@interactive-os/json-document-markdown-web/markdown-editor.css", replacement: sourceFile("packages/json-document-markdown-web/src/markdown-editor.css") }, diff --git a/site/package.json b/site/package.json index 87327fcd0..7c5e5e38f 100644 --- a/site/package.json +++ b/site/package.json @@ -100,7 +100,8 @@ "rxjs": "^7.8.2", "shiki": "^4.4.3", "tailwind-variants": "^3.3.1", - "zod": "^4.0.0" + "zod": "^4.0.0", + "@interactive-os/json-document-sheet": "*" }, "devDependencies": { "@tanstack/router-plugin": "^1.168.30", diff --git a/site/site-routes.json b/site/site-routes.json index e638515f6..481492202 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -1988,5 +1988,21 @@ "language": "ko", "navigationGroup": "Editing", "documentSource": "docs/public/editing.md" + }, + { + "path": "/docs/api/sheet", + "label": "API · Sheet", + "title": "Sheet API - json-document", + "description": "SheetHand의 표 편집 UI와 public API입니다.", + "language": "ko", + "module": { + "packageName": "@interactive-os/json-document-sheet", + "usagePaths": [ + "/demo/sheet" + ] + }, + "documentIncludes": [ + "packages/json-document-sheet/docs/editing.md" + ] } ] diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index 6fc17c11b..c5627830f 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -168,6 +168,7 @@ import { Route as PageDocsApiRichTextSuggestionRouteImport } from "./routes/_pag import { Route as PageDocsApiRichTextSuggestionReactRouteImport } from "./routes/_page/docs/api/rich-text-suggestion-react"; import { Route as PageDocsApiRichTextWebRouteImport } from "./routes/_page/docs/api/rich-text-web"; import { Route as PageDocsApiSelectionRouteImport } from "./routes/_page/docs/api/selection"; +import { Route as PageDocsApiSheetRouteImport } from "./routes/_page/docs/api/sheet"; import { Route as PageDocsApiTanstackTableRouteImport } from "./routes/_page/docs/api/tanstack-table"; import { Route as PageDocsApiUiPrimitivesReactRouteImport } from "./routes/_page/docs/api/ui-primitives-react"; import { Route as PageDocsApiWebRouteImport } from "./routes/_page/docs/api/web"; @@ -1020,6 +1021,11 @@ const PageDocsApiSelectionRoute = PageDocsApiSelectionRouteImport.update({ path: "/docs/api/selection", getParentRoute: () => PageRoute, } as any); +const PageDocsApiSheetRoute = PageDocsApiSheetRouteImport.update({ + id: "/docs/api/sheet", + path: "/docs/api/sheet", + getParentRoute: () => PageRoute, +} as any); const PageDocsApiTanstackTableRoute = PageDocsApiTanstackTableRouteImport.update({ id: "/docs/api/tanstack-table", @@ -1247,6 +1253,7 @@ export interface FileRoutesByFullPath { "/docs/api/rich-text-suggestion-react": typeof PageDocsApiRichTextSuggestionReactRoute; "/docs/api/rich-text-web": typeof PageDocsApiRichTextWebRoute; "/docs/api/selection": typeof PageDocsApiSelectionRoute; + "/docs/api/sheet": typeof PageDocsApiSheetRoute; "/docs/api/tanstack-table": typeof PageDocsApiTanstackTableRoute; "/docs/api/ui-primitives-react": typeof PageDocsApiUiPrimitivesReactRoute; "/docs/api/web": typeof PageDocsApiWebRoute; @@ -1419,6 +1426,7 @@ export interface FileRoutesByTo { "/docs/api/rich-text-suggestion-react": typeof PageDocsApiRichTextSuggestionReactRoute; "/docs/api/rich-text-web": typeof PageDocsApiRichTextWebRoute; "/docs/api/selection": typeof PageDocsApiSelectionRoute; + "/docs/api/sheet": typeof PageDocsApiSheetRoute; "/docs/api/tanstack-table": typeof PageDocsApiTanstackTableRoute; "/docs/api/ui-primitives-react": typeof PageDocsApiUiPrimitivesReactRoute; "/docs/api/web": typeof PageDocsApiWebRoute; @@ -1593,6 +1601,7 @@ export interface FileRoutesById { "/_page/docs/api/rich-text-suggestion-react": typeof PageDocsApiRichTextSuggestionReactRoute; "/_page/docs/api/rich-text-web": typeof PageDocsApiRichTextWebRoute; "/_page/docs/api/selection": typeof PageDocsApiSelectionRoute; + "/_page/docs/api/sheet": typeof PageDocsApiSheetRoute; "/_page/docs/api/tanstack-table": typeof PageDocsApiTanstackTableRoute; "/_page/docs/api/ui-primitives-react": typeof PageDocsApiUiPrimitivesReactRoute; "/_page/docs/api/web": typeof PageDocsApiWebRoute; @@ -1767,6 +1776,7 @@ export interface FileRouteTypes { | "/docs/api/rich-text-suggestion-react" | "/docs/api/rich-text-web" | "/docs/api/selection" + | "/docs/api/sheet" | "/docs/api/tanstack-table" | "/docs/api/ui-primitives-react" | "/docs/api/web" @@ -1939,6 +1949,7 @@ export interface FileRouteTypes { | "/docs/api/rich-text-suggestion-react" | "/docs/api/rich-text-web" | "/docs/api/selection" + | "/docs/api/sheet" | "/docs/api/tanstack-table" | "/docs/api/ui-primitives-react" | "/docs/api/web" @@ -2112,6 +2123,7 @@ export interface FileRouteTypes { | "/_page/docs/api/rich-text-suggestion-react" | "/_page/docs/api/rich-text-web" | "/_page/docs/api/selection" + | "/_page/docs/api/sheet" | "/_page/docs/api/tanstack-table" | "/_page/docs/api/ui-primitives-react" | "/_page/docs/api/web" @@ -3250,6 +3262,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsApiSelectionRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/api/sheet": { + id: "/_page/docs/api/sheet"; + path: "/docs/api/sheet"; + fullPath: "/docs/api/sheet"; + preLoaderRoute: typeof PageDocsApiSheetRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/api/tanstack-table": { id: "/_page/docs/api/tanstack-table"; path: "/docs/api/tanstack-table"; @@ -3491,6 +3510,7 @@ interface PageRouteChildren { PageDocsApiRichTextSuggestionReactRoute: typeof PageDocsApiRichTextSuggestionReactRoute; PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; + PageDocsApiSheetRoute: typeof PageDocsApiSheetRoute; PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; PageDocsApiWebRoute: typeof PageDocsApiWebRoute; @@ -3664,6 +3684,7 @@ const PageRouteChildren: PageRouteChildren = { PageDocsApiRichTextSuggestionReactRoute, PageDocsApiRichTextWebRoute: PageDocsApiRichTextWebRoute, PageDocsApiSelectionRoute: PageDocsApiSelectionRoute, + PageDocsApiSheetRoute: PageDocsApiSheetRoute, PageDocsApiTanstackTableRoute: PageDocsApiTanstackTableRoute, PageDocsApiUiPrimitivesReactRoute: PageDocsApiUiPrimitivesReactRoute, PageDocsApiWebRoute: PageDocsApiWebRoute, diff --git a/site/src/app/routes/_page/docs/api/sheet.tsx b/site/src/app/routes/_page/docs/api/sheet.tsx new file mode 100644 index 000000000..0d06b507f --- /dev/null +++ b/site/src/app/routes/_page/docs/api/sheet.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/sheet")({ + component: function PackageApiReferenceRoute() { + return ; + }, +}); diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx index f277763e6..5a52ee168 100644 --- a/site/src/routes/docs/DocsRoute.tsx +++ b/site/src/routes/docs/DocsRoute.tsx @@ -99,6 +99,7 @@ const docIllustrations: Record = { databaseApi: "database", annotationApi: "cursor", calendarApi: "database", + sheetApi: "database", calendarDocumentApi: "database", webApi: "terminal", contenteditableApi: "cursor", diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts index 29f999932..0301e5559 100644 --- a/site/src/routes/docs/doc-pages.ts +++ b/site/src/routes/docs/doc-pages.ts @@ -19,6 +19,7 @@ function docPage(path: string) { } export const docPages = { + sheetApi: docPage("/docs/api/sheet"), quickStart: docPage("/docs/quick-start"), modules: docPage("/docs/modules"), handsSupport: docPage("/docs/hands-support"), diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx index 5b2f8f464..8edbe0497 100644 --- a/site/src/routes/sheet-demo/SheetDemo.tsx +++ b/site/src/routes/sheet-demo/SheetDemo.tsx @@ -1,41 +1,13 @@ -import { useRef, useState } from "react"; -import { ClipboardPaste, Copy, PaintBucket, Redo2, Scissors, Undo2 } from "lucide-react"; +import { useState } from "react"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createMarkdownTableEditor } from "@interactive-os/json-document-markdown-react"; +import { createSheetEditor, createTextEditor, type SheetDocument } from "@interactive-os/json-document-editing"; +import { ProductShell, Tabs } from "@interactive-os/json-document-ui-primitives-react"; +import { SheetHand } from "@interactive-os/json-document-sheet"; +import { useEditingSnapshot } from "@interactive-os/json-document-react"; import { DemoPage } from "../../shared/demo-workbench/DemoPage"; -import { - createSheetEditor, - jsonCellText, - type EditingResult, - type SheetClipboard, - type SheetDocument, - type SheetEditor, - type SheetIntent, - type SheetSelection, -} from "@interactive-os/json-document-editing"; -import { useEditingObservation, useGridEditing } from "@interactive-os/json-document-react"; -import { - createWebClipboardSurface, - createWebClipboardTextWriter, - findWebGridCell, - gridBoundary, - moveGridPoint, - rovingFocusItemProps, - sheetClipboardCodec, - isWebEditingHostTarget, - webGridCellAddressProps, -} from "@interactive-os/json-document-web"; -import { - historyAffordance, - editingCommandFromWebKeyboardStroke, - applyAffordance, - selectAllAffordance, -} from "@interactive-os/json-document-affordance"; -import { Field, GridCell } from "@interactive-os/json-document-ui-primitives-react"; -import { Inspector } from "../../shared/ui/inspector"; -import { Command } from "@interactive-os/json-document-ui-primitives-react"; import { PageHeader } from "../../shared/ui/primitives"; -import { ProductShell } from "@interactive-os/json-document-ui-primitives-react"; -import { classes, ui } from "../../shared/ui/styles"; -import { editingItemProps } from "@interactive-os/json-document-react"; +import { Inspector } from "../../shared/ui/inspector"; const initialSheet: SheetDocument = { columns: [ @@ -51,226 +23,22 @@ const initialSheet: SheetDocument = { ], }; -const clipboardTextWriter = createWebClipboardTextWriter(); - export function SheetDemo() { - const [editor] = useState(() => createSheetEditor(initialSheet)); - const [clipboard, setClipboard] = useState(null); - const observation = useEditingObservation("Ready"); - const [clipboardSurface] = useState(() => createWebClipboardSurface({ - codec: sheetClipboardCodec, - read: () => editor.copy(), - cut: () => editor.cut()?.result ?? { ok: false, code: "selection.empty" }, - paste: (payload) => editor.dispatch({ type: "clipboard.paste", clipboard: payload }), - onResult(result) { - if (!result.ok) return observation.announce(result.code); - if (result.operation !== "paste") setClipboard(result.payload); - if (result.operation === "cut") observation.observeResult(result.result); - const verb = result.operation === "copy" ? "Copied" : result.operation === "cut" ? "Cut" : "Pasted"; - observation.announce(`${verb} ${result.payload.cells.length} × ${result.payload.cells[0]?.length ?? 0} structured cells`); - }, - })); - const surfaceRef = useRef(null); - - function dispatchIntent(intent: SheetIntent) { - const result: EditingResult = editor.dispatch(intent); - return observation.observe(intent, result); - } - - function run(action: () => { readonly ok: boolean }, successMessage: string) { - return observation.run(action, successMessage, "That action is not available here"); - } - - const focus = editor.snapshot.selection.focus; - const editing = useGridEditing({ - source: editor, - selectedPoints: editor.selectedCells, - focusPoint: focus, - onSelect: (point, mode) => { - const { rowId, columnId } = point; - run( - () => dispatchIntent({ type: "selection.set", rowId, columnId, mode }), - mode === "extend" ? "Range extended" : mode === "toggle" ? "Range toggled" : "Cell selected", - ); - }, - keyboard: { - resolve: (stroke) => editingCommandFromWebKeyboardStroke(stroke), - focusPoint: () => editor.snapshot.selection.focus ?? undefined, - neighbor: (point, command) => { - const sheet = editor.snapshot.value as SheetDocument; - const topology = { - rowIds: sheet.rows.map((row) => row.id), - columnIds: sheet.columns.map((column) => column.id), - }; - const next = command.type === "move" - ? moveGridPoint(topology, point, command.direction) - : gridBoundary(topology, point, command.edge); - return next; - }, - onDelete: () => { - run(() => dispatchIntent({ type: "selection.fill", value: null }), "Selected cells cleared"); - }, - onUndo: () => { - run(() => editor.undo(), "Undone"); - }, - onRedo: () => { - run(() => editor.redo(), "Redone"); - }, - afterMove: (point) => focusCell(surfaceRef.current, point), - ignoreCommand: (command, context) => ( - context.inField - && ((command.type === "toggle" && context.event.key === " ") - || (command.type === "delete" && context.event.key === "Backspace")) - ), - }, - }); - const snapshot = editing.snapshot; - const sheet = snapshot.value as SheetDocument; - const commands = historyAffordance(snapshot).hand; - - function copySelection() { - const next = editor.copy(); - if (next === null) return observation.announce("Select a cell first"); - setClipboard(next); - void writeClipboardText(next.text); - observation.announce(`Copied ${next.cells.length} × ${next.cells[0]?.length ?? 0} cells`); - } - - function cutSelection() { - const next = editor.cut(); - if (next === null) return observation.announce("Select a cell first"); - setClipboard(next.clipboard); - observation.observeResult(next.result); - void writeClipboardText(next.clipboard.text); - observation.announce(next.result.ok - ? `Cut ${next.clipboard.cells.length} × ${next.clipboard.cells[0]?.length ?? 0} cells` - : next.result.code); - } - - function pasteSelection() { - if (clipboard === null) return observation.announce("Copy cells first"); - run( - () => dispatchIntent({ type: "clipboard.paste", clipboard }), - `Pasted ${clipboard.cells.length} × ${clipboard.cells[0]?.length ?? 0} cells`, - ); - } - - async function writeClipboardText(text: string) { - const result = await clipboardTextWriter.writeText(text); - if (!result.ok) observation.announce(result.reason ?? result.code); - } - - return ( - -
{editor.selectedCells.length} cells · {snapshot.selection.ranges.length} ranges · revision {snapshot.revision}
-
{observation.announcement}
-
- )} - >A small editable grid for rectangular selection, TSV clipboard, history, and canonical JSON publication. - - )}> - - - - - run( - () => dispatchIntent({ type: "selection.fill", value: "Selected" }), - "Selected cells filled", - )}> - - - ); -} - -function focusCell(surface: HTMLElement | null, point: { readonly rowId: string; readonly columnId: string }) { - findWebGridCell(surface, point)?.querySelector("input")?.focus(); + const [sheet] = useState(() => createSheetEditor(initialSheet)); + const [text] = useState(() => createTextEditor(createJSONDocument("| 문법 | 표현 |\n| --- | --- |\n| 강조 | **굵게** |"))); + const [markdown] = useState(() => createMarkdownTableEditor(text, () => 0)); + const [mode, setMode] = useState<"sheet" | "markdown">("sheet"); + const editor = mode === "sheet" ? sheet : markdown; + const snapshot = useEditingSnapshot(editor); + const source = useEditingSnapshot(text); + + return 셀을 선택하고 Enter 또는 더블클릭으로 편집하세요. 방향키·Tab으로 이동하고 Shift로 범위를 확장합니다.}> + `sheet-${value}-tab`} panelId={value => `sheet-${value}-panel`} /> +
+ }> +
+
; } diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index fab3046b3..2f253bc69 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,3 +1,6 @@ +import sheetHandSource from "../../../../packages/json-document-sheet/src/sheet-hand.tsx?raw"; +import markdownTableEditorSource from "../../../../packages/json-document-markdown-react/src/markdown-table-editor.ts?raw"; +import markdownTableSource from "../../../../packages/json-document-markdown/src/table-editing.ts?raw"; import { siteRoutes } from "../../../route-registry.mjs"; import markdownSourceEditSource from "../../../../packages/json-document-markdown/src/source-edit.ts?raw"; import markdownListEditingSource from "../../../../packages/json-document-markdown/src/list-editing.ts?raw"; @@ -210,6 +213,9 @@ const excludedSources = new Set([ "routes/widgets/WidgetDemoFrame.tsx", ]); const registeredUsageSources = new Map([ + ["packages/json-document-sheet/src/sheet-hand.tsx", sheetHandSource], + ["packages/json-document-markdown-react/src/markdown-table-editor.ts", markdownTableEditorSource], + ["packages/json-document-markdown/src/table-editing.ts", markdownTableSource], ["packages/json-document-web/src/interaction-recording.ts", interactionRecordingSource], ["packages/json-document-web/src/interaction-recording-dom.ts", interactionRecordingDOMSource], ["packages/json-document-web/src/interaction-recording-archive.ts", interactionRecordingArchiveSource], @@ -395,6 +401,7 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredImplementationSources = new Map>([ + ["packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx", ["packages/json-document-markdown-react/src/markdown-table-editor.ts"]], ["packages/json-document-markdown/src/list-editing.ts", ["packages/json-document-markdown/src/source-edit.ts"]], ["packages/json-document-markdown/src/paragraph.ts", ["packages/json-document-markdown/src/list-editing.ts", "packages/json-document-markdown/src/source-edit.ts"]], ["routes/markdown-caret/MarkdownCaretRoute.tsx", ["app/interaction-recording/InteractionRecordingControls.tsx"]], @@ -403,6 +410,17 @@ const registeredImplementationSources = new Map>([ ["packages/json-document-database/src/database-hand.tsx", ["packages/json-document-database/src/database-property-control.tsx", "packages/json-document-database/src/database-view-controls.tsx"]], ]); const registeredPublicUsages = [ + { + packageName: "@interactive-os/json-document-sheet", + symbol: "SheetHand", + sourcePath: "packages/json-document-sheet/src/sheet-hand.tsx", + }, + { + packageName: "@interactive-os/json-document-markdown-react", + symbol: "createMarkdownTableEditor", + sourcePath: "packages/json-document-markdown-react/src/markdown-table-editor.ts", + }, + ...["readMarkdownTable", "replaceMarkdownTable", "markdownTableBoundary"].map(symbol => ({packageName: "@interactive-os/json-document-markdown", symbol, sourcePath: "packages/json-document-markdown/src/table-editing.ts"})), ...["createWebInteractionRecorder", "traceWebInteraction", "registerWebInteractionSource"].map(symbol => ({ packageName: "@interactive-os/json-document-web/interaction-recording", symbol, sourcePath: "packages/json-document-web/src/interaction-recording.ts" })), ...["createWebRecordingArchive", "bindWebRecordingArchive", "downloadWebInteractionRecording"].map(symbol => ({ packageName: "@interactive-os/json-document-web/interaction-recording", symbol, sourcePath: "packages/json-document-web/src/interaction-recording-archive.ts" })), ...["createWebClipboardSurface", "routeWebClipboardEvent"].flatMap(symbol => [ diff --git a/site/tests/browser/sheet-demo.spec.ts b/site/tests/browser/sheet-demo.spec.ts index 1b87492df..ebeef6858 100644 --- a/site/tests/browser/sheet-demo.spec.ts +++ b/site/tests/browser/sheet-demo.spec.ts @@ -1,168 +1,75 @@ -import { expect, test, type Page } from "@playwright/test"; - -test("Sheet repeated select-all preserves the rectangle and native field select-all", async ({ page }) => { - await page.goto("/demo/sheet"); - const surface = page.getByLabel("Editable sheet"); - await surface.focus(); - for (const modifier of ["Meta", "Control"]) { - await surface.press(`${modifier}+a`); - await surface.press(`${modifier}+a`); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(12); - } - await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); - const field = page.getByRole("textbox", { name: "Name row 1" }); - await field.click(); - await field.press("ControlOrMeta+a"); - await expect.poll(() => field.evaluate((node: HTMLInputElement) => [node.selectionStart, node.selectionEnd])).toEqual([0, 5]); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(1); -}); - -test("Sheet demo completes rectangular selection, clipboard, edit, undo, and redo", async ({ page }) => { - const consoleProblems: string[] = []; - page.on("console", (message) => { - if (message.type() === "error" || message.type() === "warning") consoleProblems.push(message.text()); - }); - - await page.goto("/demo/sheet"); - await page.getByText("Inspect editing state", { exact: true }).click(); - await expect(page.getByRole("heading", { level: 1, name: "Sheet", exact: true })).toBeVisible(); - - await page.getByRole("textbox", { name: "Name row 1" }).click(); - await page.getByRole("textbox", { name: "Status row 2" }).click({ modifiers: ["Shift"] }); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(4); - - await page.getByLabel("Sheet actions").getByRole("button", { name: "Copy", exact: true }).click(); - await expect(page.getByTestId("sheet-clipboard-tsv")).toHaveText("Alpha\tDraft\nBeta\tReady"); - - await page.getByRole("textbox", { name: "Status row 3" }).click(); - await page.getByRole("button", { name: "Paste", exact: true }).click(); - let document = await canonicalSheet(page); - expect(document.rows[2]?.cells).toEqual({ name: "Gamma", status: "Alpha", owner: "Draft" }); - expect(document.rows[3]?.cells).toEqual({ name: "Delta", status: "Beta", owner: "Ready" }); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(4); - - await page.getByRole("button", { name: "Undo", exact: true }).click(); - document = await canonicalSheet(page); - expect(document.rows[2]?.cells).toEqual({ name: "Gamma", status: "Review", owner: "June" }); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(1); - - await page.getByRole("button", { name: "Redo", exact: true }).click(); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(4); - await page.getByRole("textbox", { name: "Name row 1" }).fill("Alpha edited"); - document = await canonicalSheet(page); - expect(document.rows[0]?.cells.name).toBe("Alpha edited"); - expect(consoleProblems).toEqual([]); -}); - -test("Sheet demo fills disjoint ranges and restores their selection with undo", async ({ page }) => { - await page.goto("/demo/sheet"); - await page.getByText("Inspect editing state", { exact: true }).click(); - - await page.getByRole("textbox", { name: "Name row 1" }).click(); - await page.getByRole("textbox", { name: "Owner row 4" }).click({ modifiers: ["Meta"] }); - await page.getByRole("textbox", { name: "Status row 3" }).click({ modifiers: ["Shift"] }); - - await expect(page.locator('td[data-selected="true"]')).toHaveCount(5); - expect(JSON.parse(await page.getByTestId("sheet-selection-json").innerText()).ranges).toHaveLength(2); - - await page.getByRole("button", { name: "Fill selected" }).click(); - let document = await canonicalSheet(page); - expect(document.rows[0]?.cells.name).toBe("Selected"); - expect(document.rows[2]?.cells).toEqual({ name: "Gamma", status: "Selected", owner: "Selected" }); - expect(document.rows[3]?.cells).toEqual({ name: "Delta", status: "Selected", owner: "Selected" }); - - await page.getByRole("textbox", { name: "Owner row 1" }).click(); - await page.getByRole("button", { name: "Undo", exact: true }).click(); - document = await canonicalSheet(page); - expect(document.rows[0]?.cells.name).toBe("Alpha"); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(5); - expect(JSON.parse(await page.getByTestId("sheet-selection-json").innerText()).ranges).toHaveLength(2); - - await page.getByRole("button", { name: "Redo", exact: true }).click(); - document = await canonicalSheet(page); - expect(document.rows[0]?.cells.name).toBe("Selected"); - expect(JSON.parse(await page.getByTestId("sheet-selection-json").innerText()).ranges).toHaveLength(2); +import {expect, test} from "@playwright/test"; + +test("Sheet selects ranges, edits deliberately, moves, copies, pastes and undoes structure", async ({page}) => { + const problems: string[] = []; page.on("pageerror", error => problems.push(error.message)); + await page.goto("/demo/sheet"); + const grid = page.getByRole("grid",{name:"Project sheet"}); + const cells = grid.getByRole("gridcell"); + await cells.nth(0).click(); await cells.nth(4).click({modifiers:["Shift"]}); + await expect(grid.locator('[data-selected="true"]')).toHaveCount(4); + await cells.nth(4).press("ControlOrMeta+c"); + await cells.nth(7).click(); await cells.nth(7).press("ControlOrMeta+v"); + await expect(cells.nth(7)).toHaveText("Alpha"); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); + await expect(cells.nth(7)).toHaveText("Review"); + await cells.nth(0).click(); await cells.nth(0).press("ArrowRight"); await expect(cells.nth(1)).toBeFocused(); + await cells.nth(1).press("Enter"); + const input=grid.getByRole("textbox"); await input.fill("Changed"); await input.press("Escape"); + await expect(cells.nth(1)).toHaveText("Draft"); + await cells.nth(1).press("Enter"); await input.fill("Changed"); await input.press("Tab"); + await expect(cells.nth(1)).toHaveText("Changed"); await expect(cells.nth(2)).toBeFocused(); + await cells.nth(2).press("Shift+Tab"); await expect(cells.nth(1)).toBeFocused(); + await page.getByRole("button",{name:"열 추가",exact:true}).click(); await expect(cells).toHaveCount(16); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells).toHaveCount(12); + await page.getByRole("button",{name:"행 추가",exact:true}).click(); await expect(cells).toHaveCount(15); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells).toHaveCount(12); + await cells.nth(0).click(); await cells.nth(0).press("ControlOrMeta+a"); await cells.nth(0).press("ControlOrMeta+a"); + await expect(grid.locator('[data-selected="true"]')).toHaveCount(12); + expect(problems).toEqual([]); }); -test("Sheet demo cuts the primary rectangle and restores cells with undo", async ({ page }) => { - await page.goto("/demo/sheet"); - await page.getByText("Inspect editing state", { exact: true }).click(); - await page.getByRole("textbox", { name: "Name row 1" }).click(); - await page.getByRole("textbox", { name: "Status row 2" }).click({ modifiers: ["Shift"] }); - - await page.getByLabel("Sheet actions").getByRole("button", { name: "Cut", exact: true }).click(); - await expect(page.getByTestId("sheet-clipboard-tsv")).toHaveText("Alpha\tDraft\nBeta\tReady"); - let document = await canonicalSheet(page); - expect(document.rows[0]?.cells).toEqual({ name: null, status: null, owner: "Mina" }); - expect(document.rows[1]?.cells).toEqual({ name: null, status: null, owner: "Theo" }); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(4); - - await page.getByRole("button", { name: "Undo", exact: true }).click(); - document = await canonicalSheet(page); - expect(document.rows[0]?.cells).toEqual({ name: "Alpha", status: "Draft", owner: "Mina" }); - expect(document.rows[1]?.cells).toEqual({ name: "Beta", status: "Ready", owner: "Theo" }); +test("Bear table shares Sheet controls and document history without serializing UI", async ({page}) => { + const problems: string[] = []; page.on("pageerror", error => problems.push(error.message)); + await page.goto("/applications/bear"); + const grid = page.getByRole("grid",{name:"표 편집"}); const cells=grid.getByRole("gridcell"); + await expect(cells).toHaveCount(6); + await cells.nth(2).dblclick(); const input=grid.getByRole("textbox"); + await input.fill("새 제목"); await input.press("Enter"); await expect(cells.nth(2)).toHaveText("새 제목"); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells.nth(2)).toHaveText("제목"); + await page.getByRole("button",{name:"다시 실행",exact:true}).click(); await expect(cells.nth(2)).toHaveText("새 제목"); + await cells.nth(2).click(); await page.getByRole("button",{name:"행 추가",exact:true}).click(); await expect(cells).toHaveCount(8); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells).toHaveCount(6); + await cells.nth(0).click(); await expect(page.getByRole("button",{name:"행 삭제",exact:true})).toBeDisabled(); + await expect(page.getByText("생각이 머무는 곳.",{exact:true})).toBeVisible(); + await cells.last().click(); await cells.last().press("Tab"); + await expect(page.getByRole("textbox",{name:"Markdown 문서",exact:true})).toBeFocused(); + await page.keyboard.type("after table"); + await expect(cells).toHaveCount(6); + expect(problems).toEqual([]); }); -test("Sheet demo moves and extends selection through the Web keyboard adapter", async ({ page }) => { - await page.goto("/demo/sheet"); - await page.getByText("Inspect editing state", { exact: true }).click(); - await page.getByRole("textbox", { name: "Name row 1" }).click(); - - await page.keyboard.press("ArrowDown"); - expect(JSON.parse(await page.getByTestId("sheet-selection-json").innerText()).focus).toEqual({ - rowId: "row-2", - columnId: "name", - }); - - await page.keyboard.press("Shift+ArrowRight"); - expect(JSON.parse(await page.getByTestId("sheet-selection-json").innerText()).focus).toEqual({ - rowId: "row-2", - columnId: "status", - }); - await expect(page.locator('td[data-selected="true"]')).toHaveCount(2); - - await page.keyboard.press("Delete"); - let document = await canonicalSheet(page); - expect(document.rows[1]?.cells).toEqual({ name: null, status: null, owner: "Theo" }); - - await page.keyboard.press("ControlOrMeta+z"); - document = await canonicalSheet(page); - expect(document.rows[1]?.cells).toEqual({ name: "Beta", status: "Ready", owner: "Theo" }); +test("Sheet keeps native text selection and restores disjoint ranges after clearing", async ({page}) => { + await page.goto("/demo/sheet"); const grid=page.getByRole("grid"); const cells=grid.getByRole("gridcell"); + await cells.nth(0).click(); await cells.nth(11).click({modifiers:["Meta"]}); await cells.nth(7).click({modifiers:["Shift"]}); + await expect(grid.locator('[data-selected="true"]')).toHaveCount(5); + await cells.nth(7).press("Delete"); await expect(cells.nth(0)).toHaveText(/\s*/); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells.nth(0)).toHaveText("Alpha"); + await expect(grid.locator('[data-selected="true"]')).toHaveCount(5); + await cells.nth(0).dblclick(); const input=grid.getByRole("textbox"); + await input.press("ControlOrMeta+a"); await expect.poll(() => input.evaluate((node: HTMLInputElement) => [node.selectionStart,node.selectionEnd])).toEqual([0,5]); + await input.press("ArrowLeft"); await expect(input).toBeFocused(); + await input.press("Escape"); await cells.nth(0).click(); await cells.nth(0).press("Shift+ArrowRight"); + await expect(grid.locator('[data-selected="true"]')).toHaveCount(2); + await cells.nth(1).press("ControlOrMeta+x"); await expect(cells.nth(0)).toHaveText(/\s*/); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells.nth(0)).toHaveText("Alpha"); }); -test("Sheet demo composes native structured clipboard events with its Sheet editor", async ({ page }) => { - await page.goto("/demo/sheet"); - await page.getByText("Inspect editing state", { exact: true }).click(); - await page.getByRole("textbox", { name: "Name row 1" }).click(); - await page.getByRole("textbox", { name: "Status row 2" }).click({ modifiers: ["Shift"] }); - - const copied = await page.evaluate(() => { - const surface = document.querySelector('[aria-label="Editable sheet"]')!; - const data = new DataTransfer(); - const defaultAllowed = surface.dispatchEvent(new ClipboardEvent("copy", { clipboardData: data, bubbles: true, cancelable: true })); - return { - defaultAllowed, - structured: data.getData("application/vnd.interactive-os.sheet+json"), - text: data.getData("text/plain"), - }; - }); - expect(copied.defaultAllowed).toBe(false); - expect(copied.text).toBe("Alpha\tDraft\nBeta\tReady"); - - await page.getByRole("textbox", { name: "Status row 3" }).click(); - const pasted = await page.evaluate((structured) => { - const surface = document.querySelector('[aria-label="Editable sheet"]')!; - const data = new DataTransfer(); - data.setData("application/vnd.interactive-os.sheet+json", structured); - return surface.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true })); - }, copied.structured); - expect(pasted).toBe(false); - - const document = await canonicalSheet(page); - expect(document.rows[2]?.cells).toEqual({ name: "Gamma", status: "Alpha", owner: "Draft" }); - expect(document.rows[3]?.cells).toEqual({ name: "Delta", status: "Beta", owner: "Ready" }); +test("Sheet Usage exposes the Markdown adapter public API and source history", async ({page}) => { + await page.goto("/demo/sheet"); + await page.getByRole("tab",{name:"Markdown",exact:true}).click(); + const grid=page.getByRole("grid"); const cells=grid.getByRole("gridcell"); + await cells.nth(2).dblclick(); await grid.getByRole("textbox").fill("수정"); await grid.getByRole("textbox").press("Enter"); + await expect(cells.nth(2)).toHaveText("수정"); + await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells.nth(2)).toHaveText("강조"); + await page.getByRole("tab",{name:"Sheet",exact:true}).click(); await expect(grid.getByRole("gridcell")).toHaveCount(12); }); - -async function canonicalSheet(page: Page): Promise<{ rows: Array<{ cells: Record }> }> { - return JSON.parse(await page.getByTestId("sheet-canonical-json").innerText()) as { rows: Array<{ cells: Record }> }; -} diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index a38c87b08..f37d9db83 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -262,27 +262,16 @@ describe("Demo definition and source discovery", () => { test("registers the Sheet editor, React and Web Grid owners next to Sheet usage", async () => { const sources = await discoverDemoSources("routes/sheet-demo/SheetDemo.tsx"); - expect(sources.map((file) => file.path)).toEqual([ + expect(sources.map((file) => file.path)).toEqual(expect.arrayContaining([ "routes/sheet-demo/SheetDemo.tsx", - "packages/json-document-web/src/clipboard-event.ts", - "packages/json-document-web/src/input.ts", - "packages/json-document-ui-primitives-react/src/controls.tsx", - "packages/json-document-ui-primitives-react/src/product-shell.tsx", - "packages/json-document-react/src/use-editing.ts", - "packages/json-document-react/src/editing-observation.ts", - "packages/json-document-affordance/src/select.ts", - "packages/json-document-web/src/keyboard.ts", - "packages/json-document-web/src/clipboard.ts", + "packages/json-document-sheet/src/sheet-hand.tsx", "packages/json-document-editing/src/sheet.ts", - "packages/json-document/src/foundation/json/serializable.ts", "packages/json-document-react/src/use-grid-editing.ts", - "packages/json-document-editing/src/topology.ts", - "packages/json-document-web/src/grid-cell.ts", - "packages/json-document-ui-primitives-react/src/input-controls.tsx", + "packages/json-document-web/src/clipboard-event.ts", "packages/json-document-ui-primitives-react/src/surfaces.tsx", - "packages/json-document-web/src/pointer-session.ts", - "packages/json-document-affordance/src/interaction-handle.ts", - ]); + ])); + const hand = sources.find(file => file.path === "packages/json-document-sheet/src/sheet-hand.tsx")!; + expect(hand.referencePath).toBe("/docs/api/sheet"); const owner = sources.find((file) => file.path === "packages/json-document-editing/src/sheet.ts")!; expect(owner.referencePath).toBe("/docs/api/editing"); expect(await owner.load()).toContain("export function createSheetEditor"); diff --git a/site/tsconfig.json b/site/tsconfig.json index eeb88b5da..844bd32aa 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "baseUrl": ".", "paths": { + "@interactive-os/json-document-sheet": ["../packages/json-document-sheet/src/index.ts"], "@interactive-os/json-document-web/interaction-recording": ["../packages/json-document-web/src/interaction-recording-index.ts"], "@interactive-os/json-document-markdown": ["../packages/json-document-markdown/src/index.ts"], "@interactive-os/json-document-markdown-web": ["../packages/json-document-markdown-web/src/index.ts"], diff --git a/standards/repository-implementation-shape.md b/standards/repository-implementation-shape.md index bc2dc78f4..536eec2ae 100644 --- a/standards/repository-implementation-shape.md +++ b/standards/repository-implementation-shape.md @@ -246,6 +246,7 @@ Core는 [v3 profile](json-document-v3/profile.md), Editing의 상태·관찰· | `packages/json-document-database` | Product-facing Hand | 기본 admin UI와 customization contract를 소유하고 headless domain package를 내부 구현으로 조합 | | `packages/json-document-annotation` | Product-facing Hand | Annotation 도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 소유 | | `packages/json-document-calendar` | Product-facing Hand | Calendar editor 관찰, occurrence focus, naming, Web pointer interaction lifecycle을 정본 hook으로 유지 | +| `packages/json-document-sheet` | Product-facing Hand | 셀 선택·편집 모드·키보드·클립보드·구조 조작을 정본 SheetEditor 위에서 조합 | | `packages/json-document-canvas` | Product-facing Hand | Object Editing을 통한 단일 슬라이드 생성·편집·gesture·React UI 조합 | | `packages/json-document-object-document` | Document Type | 기존 Object와 Canvas 프로파일의 model·validation·operation·projection·JSON 정본. Core만 의존 | | `packages/json-document-calendar-document` | Document Type | Calendar model·validation·operation·projection의 공개 소유자. Editing·Selection·React peer 없이 독립 소비하며 기존 Editing export는 호환 경로 유지 | diff --git a/tsconfig.build.json b/tsconfig.build.json index e8f75b42e..e36fb0008 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -36,6 +36,7 @@ { "path": "./packages/json-document-rich-text-web" }, { "path": "./packages/json-document-rich-text-react" }, { "path": "./packages/json-document-collaboration" }, - { "path": "./packages/contenteditable-collaboration" } + { "path": "./packages/contenteditable-collaboration" }, + { "path": "./packages/json-document-sheet" } ] } From b65a761129681239606dfcf8f4f9c6bd2cd1604c 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: Sun, 13 Sep 2026 11:57:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=ED=91=9C=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EA=B3=A0=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=A0=84=ED=9B=84=20=ED=81=AC=EA=B8=B0=20=EC=9C=A0=EC=A7=80=20?= =?UTF-8?q?(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 3 ++ packages/json-document-sheet/docs/editing.md | 2 ++ packages/json-document-sheet/package.json | 3 ++ .../json-document-sheet/src/sheet-hand.tsx | 36 +++++++++++-------- site/tests/browser/sheet-demo.spec.ts | 21 +++++++++++ 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index c1c0bd138..cde508e5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7141,6 +7141,9 @@ "name": "@interactive-os/json-document-sheet", "version": "0.1.0-rc.0", "license": "MIT", + "dependencies": { + "lucide-react": "^1.33.0" + }, "devDependencies": { "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", diff --git a/packages/json-document-sheet/docs/editing.md b/packages/json-document-sheet/docs/editing.md index dd16f4865..4108f5968 100644 --- a/packages/json-document-sheet/docs/editing.md +++ b/packages/json-document-sheet/docs/editing.md @@ -19,3 +19,5 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: - 행/열 추가·삭제: 단일 History transaction. `headerRow`에서는 첫 행과 마지막 열 삭제를 막습니다. 현재 Markdown 연결은 최상위 GFM 표에 적용됩니다. 셀의 inline Markdown을 원문으로 편집하며, 줄바꿈은 공백으로, 구분자 pipe는 escape하여 표 구조를 유지합니다. 수식 계산이나 파일 가져오기는 이 Hand의 기능이 아닙니다. + +편집 여부에 관계없이 액션은 같은 위치의 아이콘으로 표시합니다. 입력창은 셀의 기존 글꼴·여백을 사용하며, 편집 진입·입력·취소는 표의 열 너비나 행 높이를 바꾸지 않습니다. 확정된 새 내용에 따른 표 크기 조정은 문서 변경으로 반영됩니다. diff --git a/packages/json-document-sheet/package.json b/packages/json-document-sheet/package.json index e24aef2a9..dedb19651 100644 --- a/packages/json-document-sheet/package.json +++ b/packages/json-document-sheet/package.json @@ -60,5 +60,8 @@ "typescript": "^5.0.0", "vitest": "^4.1.7", "@vitejs/plugin-react": "^5.2.0" + }, + "dependencies": { + "lucide-react": "^1.33.0" } } diff --git a/packages/json-document-sheet/src/sheet-hand.tsx b/packages/json-document-sheet/src/sheet-hand.tsx index adc040f6f..b2fe41d2f 100644 --- a/packages/json-document-sheet/src/sheet-hand.tsx +++ b/packages/json-document-sheet/src/sheet-hand.tsx @@ -1,9 +1,10 @@ +import { Rows3, Columns3, Plus, Minus, Undo2, Redo2 } from "lucide-react"; import { useMemo, useRef, useState, type ReactNode, type KeyboardEvent } from "react"; import { jsonCellText, type SheetDocument, type SheetEditor, type GridPoint } from "@interactive-os/json-document-editing"; import { editingItemProps, useEditingSnapshot, useGridEditing } from "@interactive-os/json-document-react"; import { editingCommandFromWebKeyboardStroke } from "@interactive-os/json-document-affordance"; import { createWebClipboardSurface, findWebGridCell, gridBoundary, moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, webGridCellAddressProps } from "@interactive-os/json-document-web"; -import { Command, ContextualControls, Toolbar, GridCell } from "@interactive-os/json-document-ui-primitives-react"; +import { Command, Toolbar, GridCell } from "@interactive-os/json-document-ui-primitives-react"; export interface SheetHandProps { readonly editor: SheetEditor; @@ -62,15 +63,14 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, rend const rowIndex = sheet.rows.findIndex(row => row.id === focus?.rowId); const columnIndex = sheet.columns.findIndex(column => column.id === focus?.columnId); const nextId = (ids: readonly string[], prefix: string) => {let i = 1; while (ids.includes(`${prefix}-${i}`)) i++; return `${prefix}-${i}`;}; - return - {context =>
event.stopPropagation()} onInput={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}> - - report(editor.dispatch({type: "row.insert", index: Math.max(headerRow ? 1 : 0, rowIndex + 1), row: {id: nextId(topology.rowIds, "row"), cells: Object.fromEntries(sheet.columns.map(column => [column.id, ""]))}}))}>행 추가 - report(editor.dispatch({type: "column.insert", index: columnIndex + 1, column: {id: nextId(topology.columnIds, "column"), label: String.fromCharCode(65 + sheet.columns.length)}}))}>열 추가 - focus && report(editor.dispatch({type: "row.delete", rowId: focus.rowId}))}>행 삭제 - focus && report(editor.dispatch({type: "column.delete", columnId: focus.columnId}))}>열 삭제 - report(editor.undo())}>실행 취소 - report(editor.redo())}>다시 실행 + return
event.stopPropagation()} onInput={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}> + + report(editor.dispatch({type: "row.insert", index: Math.max(headerRow ? 1 : 0, rowIndex + 1), row: {id: nextId(topology.rowIds, "row"), cells: Object.fromEntries(sheet.columns.map(column => [column.id, ""]))}}))}> + report(editor.dispatch({type: "column.insert", index: columnIndex + 1, column: {id: nextId(topology.columnIds, "column"), label: String.fromCharCode(65 + sheet.columns.length)}}))}> + focus && report(editor.dispatch({type: "row.delete", rowId: focus.rowId}))}> + focus && report(editor.dispatch({type: "column.delete", columnId: focus.columnId}))}> + report(editor.undo())}> + report(editor.redo())}>
@@ -81,7 +81,10 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, rend return setDraft({point, value: jsonCellText(row.cells[column.id])})}> - {active ? + {(renderCell ? renderCell(jsonCellText(row.cells[column.id])) : jsonCellText(row.cells[column.id])) || } + + {active && event.currentTarget.select()} onPointerDown={event => event.stopPropagation()} onChange={event => setDraft({point, value: event.target.value})} onBlur={finish} onKeyDown={event => { @@ -93,12 +96,17 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, rend if (event.key === "Tab") {if (tab(point, event.shiftKey)) event.preventDefault();} else {const next = moveGridPoint(topology, point, event.shiftKey ? "up" : "down"); select(next ?? point);} } - }} /> : (renderCell ? renderCell(jsonCellText(row.cells[column.id])) : jsonCellText(row.cells[column.id])) || } + }} />} ; })})}
{message && {message}} -
} - ; +
; +} + +function AxisActionIcon({axis, action}: {readonly axis: "row" | "column"; readonly action: "add" | "remove"}) { + const Axis = axis === "row" ? Rows3 : Columns3; + const Action = action === "add" ? Plus : Minus; + return ; } diff --git a/site/tests/browser/sheet-demo.spec.ts b/site/tests/browser/sheet-demo.spec.ts index ebeef6858..d08fa4167 100644 --- a/site/tests/browser/sheet-demo.spec.ts +++ b/site/tests/browser/sheet-demo.spec.ts @@ -73,3 +73,24 @@ test("Sheet Usage exposes the Markdown adapter public API and source history", a await page.getByRole("button",{name:"실행 취소",exact:true}).click(); await expect(cells.nth(2)).toHaveText("강조"); await page.getByRole("tab",{name:"Sheet",exact:true}).click(); await expect(grid.getByRole("gridcell")).toHaveCount(12); }); + +test("table actions stay icon-only and editing preserves cell geometry", async ({page}) => { + for (const path of ["/demo/sheet", "/applications/bear"]) { + await page.goto(path); + const grid=page.getByRole("grid"); const cell=grid.getByRole("gridcell").nth(2); + const toolbar=page.getByRole("toolbar",{name:"표 작업"}); + await expect(toolbar.getByRole("button")).toHaveCount(6); + for (const button of await toolbar.getByRole("button").all()) { + await expect(button.locator("svg").first()).toBeVisible(); + expect(await button.textContent()).toBe(""); + } + await cell.scrollIntoViewIfNeeded(); + const before=await cell.boundingBox(); const gridBefore=await grid.boundingBox(); + await cell.dblclick(); const input=grid.getByRole("textbox"); await expect(input).toBeVisible(); + expect(await cell.boundingBox()).toEqual(before); expect(await grid.boundingBox()).toEqual(gridBefore); + await input.fill("A long draft that must not resize the table while editing this cell"); + expect(await cell.boundingBox()).toEqual(before); expect(await grid.boundingBox()).toEqual(gridBefore); + await input.press("Escape"); + expect(await cell.boundingBox()).toEqual(before); expect(await grid.boundingBox()).toEqual(gridBefore); + } +}); From d9b9a47a467f0cd0b22259c3f61a9734e8b44429 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: Sun, 13 Sep 2026 12:46:13 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=ED=91=9C=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=EC=B1=85=EC=9E=84=EC=9D=84=20=EC=A0=95=EB=B3=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 2 + .../docs/api-reference.md | 7 +- .../docs/cell-editing.md | 7 ++ .../src/cell-editing.ts | 25 ++++++ .../json-document-affordance/src/index.ts | 2 + .../json-document-affordance/src/result.ts | 2 +- .../tests/cell-editing.test.ts | 14 +++ .../docs/api-reference.md | 44 ++++++++- packages/json-document-editing/docs/sheet.md | 19 ++++ packages/json-document-editing/package.json | 6 +- packages/json-document-editing/src/index.ts | 4 + .../src/markdown-table.ts} | 12 ++- .../src/sheet-structure.ts | 61 +++++++++++++ packages/json-document-editing/src/sheet.ts | 25 +++--- .../tests/markdown-table.test.ts} | 2 +- .../tests/sheet-structure.test.ts | 23 +++++ packages/json-document-editing/tsconfig.json | 3 +- .../docs/editing.md | 4 +- .../src/MarkdownEditingSurface.tsx | 2 +- .../json-document-markdown-react/src/index.ts | 3 +- .../tests/table-editor-compatibility.test.ts | 4 + .../json-document-react/docs/api-reference.md | 23 +++++ .../docs/rename-session.md | 5 ++ packages/json-document-react/package.json | 8 +- packages/json-document-react/src/index.ts | 1 + .../src/use-rename-session.ts | 30 +++++++ .../tests/use-rename-session.test.tsx | 16 ++++ .../json-document-sheet/docs/api-reference.md | 2 +- packages/json-document-sheet/docs/editing.md | 4 +- .../json-document-sheet/src/sheet-hand.tsx | 90 +++++++++---------- .../tests/sheet-hand.test.tsx | 16 ++++ .../json-document-web/docs/api-reference.md | 10 +++ packages/json-document-web/docs/text-keys.md | 5 ++ packages/json-document-web/src/index.ts | 2 + packages/json-document-web/src/keyboard.ts | 10 +++ .../json-document-web/tests/text-keys.test.ts | 16 ++++ .../check-canonical-module-closure.mjs | 18 ++++ site/site-routes.json | 14 ++- site/src/routes/sheet-demo/SheetDemo.tsx | 6 +- .../src/shared/demo-workbench/demo-sources.ts | 43 +++++++-- 40 files changed, 500 insertions(+), 90 deletions(-) create mode 100644 packages/json-document-affordance/docs/cell-editing.md create mode 100644 packages/json-document-affordance/src/cell-editing.ts create mode 100644 packages/json-document-affordance/tests/cell-editing.test.ts create mode 100644 packages/json-document-editing/docs/sheet.md rename packages/{json-document-markdown-react/src/markdown-table-editor.ts => json-document-editing/src/markdown-table.ts} (87%) create mode 100644 packages/json-document-editing/src/sheet-structure.ts rename packages/{json-document-markdown-react/tests/markdown-table-editor.test.ts => json-document-editing/tests/markdown-table.test.ts} (96%) create mode 100644 packages/json-document-markdown-react/tests/table-editor-compatibility.test.ts create mode 100644 packages/json-document-react/docs/rename-session.md create mode 100644 packages/json-document-react/src/use-rename-session.ts create mode 100644 packages/json-document-react/tests/use-rename-session.test.tsx create mode 100644 packages/json-document-web/docs/text-keys.md create mode 100644 packages/json-document-web/tests/text-keys.test.ts diff --git a/package-lock.json b/package-lock.json index cde508e5a..6e27b3b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6819,6 +6819,7 @@ "devDependencies": { "@interactive-os/json-document": "*", "@interactive-os/json-document-calendar-document": "*", + "@interactive-os/json-document-markdown": "*", "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", @@ -6828,6 +6829,7 @@ "peerDependencies": { "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-calendar-document": "^0.1.0-rc.0", + "@interactive-os/json-document-markdown": "^0.1.0-rc.0", "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-selection": "^0.1.0-rc.0" } diff --git a/packages/json-document-affordance/docs/api-reference.md b/packages/json-document-affordance/docs/api-reference.md index 37cb9b461..e4bc4c6d9 100644 --- a/packages/json-document-affordance/docs/api-reference.md +++ b/packages/json-document-affordance/docs/api-reference.md @@ -69,7 +69,7 @@ type AffordanceHand = readonly edge?: "start" | "end"; readonly operation: "replace" | "extend"; } - | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel" } + | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel"; readonly initialText?: string; readonly move?: "up" | "down" } | { readonly type: "activate" } | { readonly type: "press"; @@ -241,6 +241,11 @@ caretAffordance(input: { readonly type: "pointer"; readonly dragging?: boolean; ```ts caretCursor(direction: "horizontal" | "vertical"): "text" | "vertical-text" ``` +## `cellEditingAffordance` + +```ts +cellEditingAffordance(stroke: WebKeyboardStroke, state: { readonly editing: boolean; readonly allSelected: boolean; }): AffordancePreview +``` ## `clickCountAffordance` ```ts diff --git a/packages/json-document-affordance/docs/cell-editing.md b/packages/json-document-affordance/docs/cell-editing.md new file mode 100644 index 000000000..9a3f969a1 --- /dev/null +++ b/packages/json-document-affordance/docs/cell-editing.md @@ -0,0 +1,7 @@ +# 셀 편집 조작 + +`cellEditingAffordance(stroke, {editing, allSelected})`는 기존 rename·focus·select-all 계약을 조합합니다. Enter/F2 또는 문자 입력은 편집 시작, Enter는 확정 후 아래 이동, Shift+Enter는 위 이동, Escape는 취소, Tab/Shift+Tab은 셀 이동을 뜻합니다. + +편집 중 일반 문자·방향키·Mod+A는 native field에 남깁니다. `rename` hand의 `initialText`는 입력으로 시작하는 초안, `move`는 성공적인 확정 이후 이동 방향입니다. 조합 중 키는 Web owner의 `isWebComposingKey`로 먼저 제외합니다. + +[Sheet Usage](/demo/sheet)에서 `useRenameSession`과 함께 사용합니다. 초안 확정이 거절되면 이동하지 않고 초안을 유지해야 합니다. diff --git a/packages/json-document-affordance/src/cell-editing.ts b/packages/json-document-affordance/src/cell-editing.ts new file mode 100644 index 000000000..1f6d915f4 --- /dev/null +++ b/packages/json-document-affordance/src/cell-editing.ts @@ -0,0 +1,25 @@ +import { createWebKeyboardAdapter, webKeyboardText, type WebKeyboardStroke } from "@interactive-os/json-document-web"; +import { focusAffordance, renameAffordance, selectAllAffordance } from "./select.js"; +import type { AffordancePreview } from "./result.js"; + +const activation = createWebKeyboardAdapter<"enter" | "previous" | "begin" | "cancel">({defaults: false, + keymap: {Enter: "enter", "Shift-Enter": "previous", F2: "begin", Escape: "cancel"}}); + +/** Cell edit meaning. Native text keys stay native while an edit is active. */ +export function cellEditingAffordance(stroke: WebKeyboardStroke, state: {readonly editing: boolean; readonly allSelected: boolean}): AffordancePreview { + if (!stroke.metaKey && !stroke.ctrlKey && !stroke.altKey) { + const focus = focusAffordance(stroke); + if (focus.hand?.type === "tab") return focus; + } + const action = activation.resolve(stroke); + if (state.editing) { + if (action === "cancel") return renameAffordance({key: "Escape"}); + if (action === "enter" || action === "previous") return {hand: {type: "rename", action: "commit", move: action === "previous" ? "up" : "down"}}; + return {hand: null}; + } + const all = selectAllAffordance(stroke, state, {repeat: "preserve"}); + if (all.hand) return all; + if (action === "enter" || action === "begin") return renameAffordance({key: "F2"}); + const text = webKeyboardText(stroke); + return text === null ? {hand: null} : {hand: {type: "rename", action: "begin", initialText: text}}; +} diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index 52b0fcd97..801d040f3 100644 --- a/packages/json-document-affordance/src/index.ts +++ b/packages/json-document-affordance/src/index.ts @@ -151,3 +151,5 @@ export { selectAllAffordance, typeaheadAffordance, } from "./select.js"; + +export { cellEditingAffordance } from "./cell-editing.js"; diff --git a/packages/json-document-affordance/src/result.ts b/packages/json-document-affordance/src/result.ts index a38848458..2e34a5591 100644 --- a/packages/json-document-affordance/src/result.ts +++ b/packages/json-document-affordance/src/result.ts @@ -46,7 +46,7 @@ export type AffordanceHand = readonly edge?: "start" | "end"; readonly operation: "replace" | "extend"; } - | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel" } + | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel"; readonly initialText?: string; readonly move?: "up" | "down" } | { readonly type: "activate" } | { readonly type: "press"; diff --git a/packages/json-document-affordance/tests/cell-editing.test.ts b/packages/json-document-affordance/tests/cell-editing.test.ts new file mode 100644 index 000000000..2fa4502a2 --- /dev/null +++ b/packages/json-document-affordance/tests/cell-editing.test.ts @@ -0,0 +1,14 @@ +import {expect, test} from "vitest"; +import {cellEditingAffordance} from "../src/index.js"; +const stroke=(key: string, modifiers={})=>({key,shiftKey:false,metaKey:false,ctrlKey:false,...modifiers}); +test("cell editing distinguishes native typing, activation, commit and grid focus", () => { + const editing={editing:true,allSelected:false}, idle={...editing,editing:false}; + expect(cellEditingAffordance(stroke("Enter"),idle).hand).toMatchObject({type:"rename",action:"begin"}); + expect(cellEditingAffordance(stroke("Enter",{shiftKey:true}),editing).hand).toMatchObject({action:"commit",move:"up"}); + expect(cellEditingAffordance(stroke("한"),idle).hand).toMatchObject({action:"begin",initialText:"한"}); + expect(cellEditingAffordance(stroke("a"),editing).hand).toBeNull(); + expect(cellEditingAffordance(stroke("a",{ctrlKey:true}),editing).hand).toBeNull(); + expect(cellEditingAffordance(stroke("a",{metaKey:true}),{...idle,allSelected:true}).hand).toEqual({type:"select-all"}); + expect(cellEditingAffordance(stroke("Tab",{shiftKey:true}),editing).hand).toMatchObject({type:"tab",direction:"prev"}); + expect(cellEditingAffordance(stroke("Escape"),editing).hand).toMatchObject({action:"cancel"}); +}); diff --git a/packages/json-document-editing/docs/api-reference.md b/packages/json-document-editing/docs/api-reference.md index 79d6caa93..1f1e15957 100644 --- a/packages/json-document-editing/docs/api-reference.md +++ b/packages/json-document-editing/docs/api-reference.md @@ -616,6 +616,11 @@ createEditingSession(options: EditingSessionOptions ```ts createKanbanEditor(source: EditingDocumentSource, options?: EditingHistoryOptions): KanbanEditor ``` +## `createMarkdownTableEditor` + +```ts +createMarkdownTableEditor(text: TextEditor, position: () => number): SheetEditor +``` ## `createObjectEditor` ```ts @@ -1524,6 +1529,11 @@ interface SheetColumn extends Record { readonly label: string; } ``` +## `sheetColumnLabel` + +```ts +sheetColumnLabel(index: number): string +``` ## `SheetDocument` ```ts @@ -1536,6 +1546,7 @@ interface SheetDocument extends Record { ```ts interface SheetEditor { + readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; readonly selectedCells: ReadonlyArray; selectedCellsIn(topology: SheetTopology): ReadonlyArray; @@ -1551,6 +1562,7 @@ interface SheetEditor { ```ts interface SheetEditorOptions extends EditingHistoryOptions { + readonly structure?: SheetStructurePolicy; /** Restore selection when projecting a new source snapshot; missing cells are reconciled. */ readonly selection?: SheetSelection; } @@ -1559,10 +1571,7 @@ interface SheetEditorOptions extends EditingHistoryOptions { ```ts type SheetIntent = - | { readonly type: "row.insert"; readonly index: number; readonly row: SheetRow } - | { readonly type: "row.delete"; readonly rowId: string } - | { readonly type: "column.insert"; readonly index: number; readonly column: SheetColumn } - | { readonly type: "column.delete"; readonly columnId: string } + | SheetStructureIntent | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; @@ -1623,6 +1632,33 @@ interface SheetSelection extends Record { readonly primaryIndex: number | null; } ``` +## `SheetStructureActions` + +```ts +interface SheetStructureActions { + readonly insertRow: SheetStructureIntent; + readonly insertColumn: SheetStructureIntent; + readonly deleteRow: SheetStructureIntent | null; + readonly deleteColumn: SheetStructureIntent | null; +} +``` +## `SheetStructureIntent` + +```ts +type SheetStructureIntent = + | { readonly type: "row.insert"; readonly index: number; readonly row?: SheetRow } + | { readonly type: "row.delete"; readonly rowId: string } + | { readonly type: "column.insert"; readonly index: number; readonly column?: SheetColumn } + | { readonly type: "column.delete"; readonly columnId: string }; +``` +## `SheetStructurePolicy` + +```ts +interface SheetStructurePolicy { + readonly headerRows?: number; + readonly minimumColumns?: number; +} +``` ## `SheetTopology` ```ts diff --git a/packages/json-document-editing/docs/sheet.md b/packages/json-document-editing/docs/sheet.md new file mode 100644 index 000000000..d33cfa124 --- /dev/null +++ b/packages/json-document-editing/docs/sheet.md @@ -0,0 +1,19 @@ +# Sheet 구조와 Markdown 편집 + +`SheetEditor.structure`는 현재 선택과 구조 정책에서 실행 가능한 행·열 Intent를 제공합니다. 삭제가 금지되면 해당 action은 `null`입니다. Hand는 헤더나 최소 열 수를 다시 판정하지 않습니다. 같은 정책은 직접 `dispatch`한 Intent에도 적용됩니다. + +```ts +import { createSheetEditor, sheetColumnLabel } from '@interactive-os/json-document-editing'; +const editor = createSheetEditor({ + columns: [{id: 'a', label: sheetColumnLabel(0)}], + rows: [{id: 'header', cells: {a: '제목'}}], +}, {structure: {headerRows: 1, minimumColumns: 1}}); +editor.dispatch(editor.structure.insertRow); +// row.insert의 row와 column.insert의 column을 생략하면 owner가 ID와 빈 셀을 구성합니다. +``` + +`sheetColumnLabel`은 0부터 시작하는 열 좌표를 A…Z, AA…로 표현합니다. 사용자 정의 열 label과 ID를 넘기는 기존 Intent는 계속 지원합니다. + +`createMarkdownTableEditor(textEditor, position)`는 React 없이 Markdown과 Sheet를 연결합니다. Markdown 문법은 Markdown package를 사용하고, 원문 변경과 Undo/Redo는 TextEditor가 소유합니다. 표 구조는 `headerRows: 1`, `minimumColumns: 1` 정책을 사용합니다. + +기존 `@interactive-os/json-document-markdown-react`의 동일 export는 deprecated 재export로 유지합니다. 새 소비자는 Editing package에서 import합니다. [Sheet Usage](/demo/sheet)의 Markdown 탭과 [Bear](/applications/bear)가 이 경로를 사용합니다. diff --git a/packages/json-document-editing/package.json b/packages/json-document-editing/package.json index edef275ce..c14ee3bac 100644 --- a/packages/json-document-editing/package.json +++ b/packages/json-document-editing/package.json @@ -37,7 +37,8 @@ "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-calendar-document": "^0.1.0-rc.0", "@interactive-os/json-document": "^3.0.0", - "@interactive-os/json-document-selection": "^0.1.0-rc.0" + "@interactive-os/json-document-selection": "^0.1.0-rc.0", + "@interactive-os/json-document-markdown": "^0.1.0-rc.0" }, "devDependencies": { "@interactive-os/json-document-object-document": "*", @@ -46,6 +47,7 @@ "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", - "vitest": "^4.1.7" + "vitest": "^4.1.7", + "@interactive-os/json-document-markdown": "*" } } diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 212f77c11..e59d1a1f0 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -230,3 +230,7 @@ export type { } from "./calendar-time-grid-pointer.js"; export { createTextEditor, clampTextSelection, type TextEditor, type TextSelection } from "./text.js"; export { diffText, type TextChange } from "./text-change.js"; + +export { sheetColumnLabel } from "./sheet-structure.js"; +export type { SheetStructureIntent, SheetStructurePolicy, SheetStructureActions } from "./sheet-structure.js"; +export { createMarkdownTableEditor } from "./markdown-table.js"; diff --git a/packages/json-document-markdown-react/src/markdown-table-editor.ts b/packages/json-document-editing/src/markdown-table.ts similarity index 87% rename from packages/json-document-markdown-react/src/markdown-table-editor.ts rename to packages/json-document-editing/src/markdown-table.ts index b153c9a28..76a5a6a3b 100644 --- a/packages/json-document-markdown-react/src/markdown-table-editor.ts +++ b/packages/json-document-editing/src/markdown-table.ts @@ -1,4 +1,8 @@ -import { createSheetEditor, jsonCellText, type SheetDocument, type SheetEditor, type SheetIntent, type SheetSelection, type SheetPoint, type EditingResult, type TextEditor } from "@interactive-os/json-document-editing"; +import { createSheetEditor, type SheetDocument, type SheetEditor, type SheetIntent, type SheetSelection, type SheetPoint } from "./sheet.js"; +import { jsonCellText } from "./cell-text.js"; +import { sheetColumnLabel } from "./sheet-structure.js"; +import type { EditingResult } from "./session.js"; +import type { TextEditor } from "./text.js"; import { readMarkdownTable, replaceMarkdownTable, type MarkdownTable } from "@interactive-os/json-document-markdown"; /** Adapt source table transactions to Sheet, retaining the TextEditor as the only history owner. */ @@ -16,8 +20,8 @@ export function createMarkdownTableEditor(text: TextEditor, position: () => numb observed = text.text; const table = readMarkdownTable(observed, position()); const width = table?.align.length || table?.rows[0]?.length || 0; - const columns = Array.from({length: width}, (_, i) => ({id: `c${i}`, label: String.fromCharCode(65 + i)})); - sheet = createSheetEditor({columns, rows: (table?.rows ?? []).map((row, i) => ({id: `r${i}`, cells: Object.fromEntries(columns.map((column, j) => [column.id, row[j] ?? ""]))}))}, selected ? {selection: selected} : {}); + const columns = Array.from({length: width}, (_, i) => ({id: `c${i}`, label: sheetColumnLabel(i)})); + sheet = createSheetEditor({columns, rows: (table?.rows ?? []).map((row, i) => ({id: `r${i}`, cells: Object.fromEntries(columns.map((column, j) => [column.id, row[j] ?? ""]))}))}, {structure: {headerRows: 1, minimumColumns: 1}, ...(selected ? {selection: selected} : {})}); }; const snapshot = () => {read(); return {...sheet.snapshot, revision, canUndo: text.snapshot.canUndo, canRedo: text.snapshot.canRedo};}; const publish = () => {revision++; const next = snapshot(); listeners.forEach(listener => listener(next));}; @@ -25,7 +29,6 @@ export function createMarkdownTableEditor(text: TextEditor, position: () => numb read(); const table = readMarkdownTable(text.text, position()); if (!table) return {ok: false, code: "table.unavailable"}; - if ((intent.type === "row.insert" && intent.index === 0) || (intent.type === "row.delete" && intent.rowId === "r0") || (intent.type === "column.delete" && table.align.length <= 1)) return {ok: false, code: "table.header-required"}; const result = sheet.dispatch(intent); if (!result.ok) return result; if (intent.type === "selection.set" || intent.type === "selection.select-all") {publish(); return {ok: true, snapshot: snapshot()};} @@ -51,6 +54,7 @@ export function createMarkdownTableEditor(text: TextEditor, position: () => numb publish(); return {ok: true, snapshot: snapshot()}; }; return { + get structure() {read(); return sheet.structure;}, get snapshot() {return snapshot();}, get selectedCells() {read(); return sheet.selectedCells;}, selectedCellsIn(topology) {read(); return sheet.selectedCellsIn(topology);}, diff --git a/packages/json-document-editing/src/sheet-structure.ts b/packages/json-document-editing/src/sheet-structure.ts new file mode 100644 index 000000000..f36a50a39 --- /dev/null +++ b/packages/json-document-editing/src/sheet-structure.ts @@ -0,0 +1,61 @@ +import type { SheetColumn, SheetDocument, SheetRow, SheetSelection } from "./sheet.js"; + +export type SheetStructureIntent = + | { readonly type: "row.insert"; readonly index: number; readonly row?: SheetRow } + | { readonly type: "row.delete"; readonly rowId: string } + | { readonly type: "column.insert"; readonly index: number; readonly column?: SheetColumn } + | { readonly type: "column.delete"; readonly columnId: string }; + +export interface SheetStructurePolicy { + readonly headerRows?: number; + readonly minimumColumns?: number; +} +export interface SheetStructureActions { + readonly insertRow: SheetStructureIntent; + readonly insertColumn: SheetStructureIntent; + readonly deleteRow: SheetStructureIntent | null; + readonly deleteColumn: SheetStructureIntent | null; +} + +/** Zero-based spreadsheet column coordinates: A … Z, AA … AZ, BA … */ +export function sheetColumnLabel(index: number): string { + if (!Number.isSafeInteger(index) || index < 0) throw new RangeError("Column index must be a nonnegative safe integer"); + let remaining = index + 1, label = ""; + while (remaining > 0) { remaining--; label = String.fromCharCode(65 + remaining % 26) + label; remaining = Math.floor(remaining / 26); } + return label; +} + +export function sheetStructureViolation(document: SheetDocument, intent: SheetStructureIntent, policy: SheetStructurePolicy): string | null { + const headerRows = policy.headerRows ?? 0; + if (intent.type === "row.insert" && intent.index < headerRows) return "sheet.header-protected"; + if (intent.type === "row.delete" && document.rows.findIndex(row => row.id === intent.rowId) >= 0 && document.rows.findIndex(row => row.id === intent.rowId) < headerRows) return "sheet.header-protected"; + if (intent.type === "column.delete" && document.columns.length <= (policy.minimumColumns ?? 0)) return "sheet.minimum-columns"; + return null; +} + +export function sheetStructureActions(document: SheetDocument, selection: SheetSelection, policy: SheetStructurePolicy): SheetStructureActions { + const focus = selection.focus; + const row = document.rows.findIndex(row => row.id === focus?.rowId); + const column = document.columns.findIndex(column => column.id === focus?.columnId); + const deleteRow: SheetStructureIntent | null = row < 0 ? null : {type: "row.delete", rowId: focus!.rowId}; + const deleteColumn: SheetStructureIntent | null = column < 0 ? null : {type: "column.delete", columnId: focus!.columnId}; + return { + insertRow: {type: "row.insert", index: Math.max(policy.headerRows ?? 0, row + 1)}, + insertColumn: {type: "column.insert", index: column + 1}, + deleteRow: deleteRow && !sheetStructureViolation(document, deleteRow, policy) ? deleteRow : null, + deleteColumn: deleteColumn && !sheetStructureViolation(document, deleteColumn, policy) ? deleteColumn : null, + }; +} + +export function createSheetRow(document: SheetDocument): SheetRow { + return {id: availableId(document.rows, "row"), cells: Object.fromEntries(document.columns.map(column => [column.id, ""]))}; +} +export function createSheetColumn(document: SheetDocument): SheetColumn { + return {id: availableId(document.columns, "column"), label: sheetColumnLabel(document.columns.length)}; +} +function availableId(values: ReadonlyArray<{readonly id: string}>, prefix: string): string { + const ids = new Set(values.map(value => value.id)); + let index = 1; + while (ids.has(`${prefix}-${index}`)) index++; + return `${prefix}-${index}`; +} diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index 0bbeb8857..872772625 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -1,3 +1,4 @@ +import { createSheetRow, createSheetColumn, sheetStructureActions, sheetStructureViolation, type SheetStructureIntent, type SheetStructurePolicy, type SheetStructureActions } from "./sheet-structure.js"; import { buildPointer, isJSONValue, @@ -84,10 +85,7 @@ export const sheetClipboardFormat = { }; export type SheetIntent = - | { readonly type: "row.insert"; readonly index: number; readonly row: SheetRow } - | { readonly type: "row.delete"; readonly rowId: string } - | { readonly type: "column.insert"; readonly index: number; readonly column: SheetColumn } - | { readonly type: "column.delete"; readonly columnId: string } + | SheetStructureIntent | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; @@ -113,6 +111,7 @@ export type SheetIntent = }; export interface SheetEditor { + readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; readonly selectedCells: ReadonlyArray; selectedCellsIn(topology: SheetTopology): ReadonlyArray; @@ -125,6 +124,7 @@ export interface SheetEditor { } export interface SheetEditorOptions extends EditingHistoryOptions { + readonly structure?: SheetStructurePolicy; /** Restore selection when projecting a new source snapshot; missing cells are reconciled. */ readonly selection?: SheetSelection; } @@ -209,15 +209,19 @@ export function createSheetEditor(source: EditingDocumentSource, function dispatch(intent: SheetIntent): EditingResult { if (intent.type === "row.insert" || intent.type === "row.delete" || intent.type === "column.insert" || intent.type === "column.delete") { const current = value(); + const violation = sheetStructureViolation(current, intent, options.structure ?? {}); + if (violation) return failure(violation); let rows = [...current.rows], columns = [...current.columns]; if (intent.type === "row.insert") { - if (!Number.isInteger(intent.index) || intent.index < 0 || intent.index > rows.length || rows.some(row => row.id === intent.row.id) || !intent.row.id) return failure("row.invalid-insert"); - if (columns.some(column => !Object.hasOwn(intent.row.cells, column.id))) return failure("row.missing-cell"); - rows.splice(intent.index, 0, intent.row); + const inserted = intent.row ?? createSheetRow(current); + if (!Number.isInteger(intent.index) || intent.index < 0 || intent.index > rows.length || rows.some(row => row.id === inserted.id) || !inserted.id) return failure("row.invalid-insert"); + if (columns.some(column => !Object.hasOwn(inserted.cells, column.id))) return failure("row.missing-cell"); + rows.splice(intent.index, 0, inserted); } else if (intent.type === "column.insert") { - if (!Number.isInteger(intent.index) || intent.index < 0 || intent.index > columns.length || columns.some(column => column.id === intent.column.id) || !intent.column.id) return failure("column.invalid-insert"); - columns.splice(intent.index, 0, intent.column); - rows = rows.map(row => ({...row, cells: {...row.cells, [intent.column.id]: ""}})); + const inserted = intent.column ?? createSheetColumn(current); + if (!Number.isInteger(intent.index) || intent.index < 0 || intent.index > columns.length || columns.some(column => column.id === inserted.id) || !inserted.id) return failure("column.invalid-insert"); + columns.splice(intent.index, 0, inserted); + rows = rows.map(row => ({...row, cells: {...row.cells, [inserted.id]: ""}})); } else if (intent.type === "row.delete") { if (!rows.some(row => row.id === intent.rowId)) return failure("row.not-found"); rows = rows.filter(row => row.id !== intent.rowId); @@ -322,6 +326,7 @@ export function createSheetEditor(source: EditingDocumentSource, } return { + get structure() { return sheetStructureActions(value(), session.snapshot.selection, options.structure ?? {}); }, get snapshot() { return session.snapshot; }, get selectedCells() { return selectedCells(); }, selectedCellsIn: (topology) => selectedCells(topology), diff --git a/packages/json-document-markdown-react/tests/markdown-table-editor.test.ts b/packages/json-document-editing/tests/markdown-table.test.ts similarity index 96% rename from packages/json-document-markdown-react/tests/markdown-table-editor.test.ts rename to packages/json-document-editing/tests/markdown-table.test.ts index fb72d7e0e..202958d82 100644 --- a/packages/json-document-markdown-react/tests/markdown-table-editor.test.ts +++ b/packages/json-document-editing/tests/markdown-table.test.ts @@ -1,7 +1,7 @@ import {expect, test} from "vitest"; import {createJSONDocument} from "@interactive-os/json-document"; import {createTextEditor, type SheetDocument} from "@interactive-os/json-document-editing"; -import {createMarkdownTableEditor} from "../src/markdown-table-editor.js"; +import {createMarkdownTableEditor} from "../src/markdown-table.js"; test("cell and structure edits use whole-document history and never store UI labels", () => { const original = "| A | B |\n| --- | --- |\n| x | y |\n\nend"; const text = createTextEditor(createJSONDocument(original)); diff --git a/packages/json-document-editing/tests/sheet-structure.test.ts b/packages/json-document-editing/tests/sheet-structure.test.ts index b3960b90d..efc409113 100644 --- a/packages/json-document-editing/tests/sheet-structure.test.ts +++ b/packages/json-document-editing/tests/sheet-structure.test.ts @@ -17,3 +17,26 @@ test("row and column transactions preserve metadata and restore focus with undo" expect(editor.dispatch({type:"column.insert",index:10,column:{id:"c",label:"C"}}).ok).toBe(false); expect(editor.dispatch({type:"row.insert",index:0,row:{id:"bad",cells:{}}}).ok).toBe(false); }); + +test("default creation allocates collision-free ids and alphabetic column coordinates", async () => { + const {sheetColumnLabel}=await import("../src/index.js"); + expect([0,25,26,51,52,701,702].map(sheetColumnLabel)).toEqual(["A","Z","AA","AZ","BA","ZZ","AAA"]); + expect(() => sheetColumnLabel(-1)).toThrow(); + const columns=Array.from({length:26},(_,i)=>({id:`column-${i+1}`,label:sheetColumnLabel(i)})); + const editor=createSheetEditor({columns,rows:[{id:"row-1",cells:Object.fromEntries(columns.map(c=>[c.id,""]))}]}); + expect(editor.dispatch(editor.structure.insertRow).ok).toBe(true); + expect((editor.snapshot.value as SheetDocument).rows[1]!.id).toBe("row-2"); + expect(editor.dispatch(editor.structure.insertColumn).ok).toBe(true); + expect((editor.snapshot.value as SheetDocument).columns[1]).toEqual({id:"column-27",label:"AA"}); + editor.undo(); expect((editor.snapshot.value as SheetDocument).columns).toHaveLength(26); +}); + +test("capabilities and direct intents enforce the same structure policy", () => { + const editor=createSheetEditor({columns:[{id:"a",label:"A"}],rows:[{id:"header",cells:{a:"A"}},{id:"body",cells:{a:"value"}}]}, {structure:{headerRows:1,minimumColumns:1}}); + expect(editor.structure.deleteRow).toBeNull(); expect(editor.structure.deleteColumn).toBeNull(); + expect(editor.dispatch({type:"row.insert",index:0}).ok).toBe(false); + editor.dispatch({type:"selection.set",rowId:"body",columnId:"a"}); + expect(editor.structure.deleteRow).toEqual({type:"row.delete",rowId:"body"}); + expect(editor.dispatch({type:"row.delete",rowId:"header"}).ok).toBe(false); + expect(editor.dispatch({type:"column.delete",columnId:"a"}).ok).toBe(false); +}); diff --git a/packages/json-document-editing/tsconfig.json b/packages/json-document-editing/tsconfig.json index 17fb2dcdd..ec9c00769 100644 --- a/packages/json-document-editing/tsconfig.json +++ b/packages/json-document-editing/tsconfig.json @@ -9,7 +9,8 @@ { "path": "../json-document-object-document" }, { "path": "../json-document-calendar-document" }, { "path": "../json-document" }, - { "path": "../json-document-selection" } + { "path": "../json-document-selection" }, + { "path": "../json-document-markdown" } ], "include": ["src/**/*.ts"] } diff --git a/packages/json-document-markdown-react/docs/editing.md b/packages/json-document-markdown-react/docs/editing.md index d10844a2b..829435c2a 100644 --- a/packages/json-document-markdown-react/docs/editing.md +++ b/packages/json-document-markdown-react/docs/editing.md @@ -35,4 +35,6 @@ Markdown Web과 공용 contenteditable 투영을 사용합니다. ## Sheet 표 편집 -`MarkdownEditingSurface`는 최상위 GFM 표에 `SheetHand`를 연결합니다. `createMarkdownTableEditor(editor, position)`는 Sheet Intent를 Markdown table 원문 변경으로 변환하고 문서 전체 Undo/Redo를 재사용합니다. 셀 UI와 입력 상태는 Hand, 문법 처리는 Markdown, 원문과 History는 TextEditor가 소유합니다. [Sheet API](/docs/api/sheet)와 [Bear](/applications/bear)에서 확인할 수 있습니다. +`MarkdownEditingSurface`는 최상위 GFM 표에 `SheetHand`를 연결합니다. `@interactive-os/json-document-editing`의 `createMarkdownTableEditor(editor, position)`는 Sheet Intent를 Markdown table 원문 변경으로 변환하고 문서 전체 Undo/Redo를 재사용합니다. 셀 UI와 입력 상태는 Hand, 문법 처리는 Markdown, 원문과 History는 TextEditor가 소유합니다. [Sheet API](/docs/api/sheet)와 [Bear](/applications/bear)에서 확인할 수 있습니다. + +이 패키지의 기존 `createMarkdownTableEditor` export는 호환용 deprecated 재export입니다. 편집 어댑터 구현과 API 문서는 [Editing owner](/docs/api/editing)에 있습니다. diff --git a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx index 32f89bda4..860f88be4 100644 --- a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx +++ b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx @@ -3,7 +3,7 @@ import { markdownTableBoundary, readMarkdownTable } from "@interactive-os/json-d import { MarkdownRenderer } from "./MarkdownRenderer.js"; import { createRoot } from "react-dom/client"; import { SheetHand } from "@interactive-os/json-document-sheet"; -import { createMarkdownTableEditor } from "./markdown-table-editor.js"; +import { createMarkdownTableEditor } from "@interactive-os/json-document-editing"; import type { TextEditor } from "@interactive-os/json-document-editing"; import { createMarkdownEditingBinding } from "@interactive-os/json-document-markdown-web"; diff --git a/packages/json-document-markdown-react/src/index.ts b/packages/json-document-markdown-react/src/index.ts index 80658f7de..e85cd0b09 100644 --- a/packages/json-document-markdown-react/src/index.ts +++ b/packages/json-document-markdown-react/src/index.ts @@ -1,4 +1,5 @@ export { MarkdownRenderer, type MarkdownComponents, type MarkdownRendererProps } from "./MarkdownRenderer.js"; export { projectStreamingMarkdown, type StreamingMarkdownProjection } from "./streaming-markdown.js"; export { MarkdownEditingSurface, type MarkdownEditingSurfaceProps } from "./MarkdownEditingSurface.js"; -export { createMarkdownTableEditor } from "./markdown-table-editor.js"; +/** @deprecated Import the source editor from @interactive-os/json-document-editing. */ +export { createMarkdownTableEditor } from "@interactive-os/json-document-editing"; diff --git a/packages/json-document-markdown-react/tests/table-editor-compatibility.test.ts b/packages/json-document-markdown-react/tests/table-editor-compatibility.test.ts new file mode 100644 index 000000000..a8431c41d --- /dev/null +++ b/packages/json-document-markdown-react/tests/table-editor-compatibility.test.ts @@ -0,0 +1,4 @@ +import {expect, test} from "vitest"; +import {createMarkdownTableEditor as canonical} from "@interactive-os/json-document-editing"; +import {createMarkdownTableEditor as legacy} from "../src/index.js"; +test("legacy React export delegates to the canonical source editor", () => {expect(legacy).toBe(canonical);}); diff --git a/packages/json-document-react/docs/api-reference.md b/packages/json-document-react/docs/api-reference.md index bb95e25fa..bd1f14753 100644 --- a/packages/json-document-react/docs/api-reference.md +++ b/packages/json-document-react/docs/api-reference.md @@ -246,6 +246,14 @@ interface GridEditingKeyboardOptions { readonly ignoreCommand?: EditingKeyboardOptions["ignoreCommand"]; } ``` +## `RenameSessionBinding` + +```ts +interface RenameSessionBinding { + readonly snapshot: RenameSessionSnapshot | null; + readonly session: RenameSession; +} +``` ## `restoreTextCursor` ```ts @@ -383,6 +391,21 @@ useJSONDocumentValue(document: JSONDocument): JSONValue ```ts useReactConnector(document: JSONDocument): JSONValue ``` +## `useRenameSession` + +```ts +useRenameSession(options: UseRenameSessionOptions): RenameSessionBinding +``` +## `UseRenameSessionOptions` + +```ts +interface UseRenameSessionOptions { + /** Replacing the owner discards the old owner's draft. */ + readonly owner: object; + readonly tryCommit: (key: Key, draft: string) => boolean; + readonly onFinish?: (key: Key) => void; +} +``` ## `useRestoreElementFocus` ```ts diff --git a/packages/json-document-react/docs/rename-session.md b/packages/json-document-react/docs/rename-session.md new file mode 100644 index 000000000..1e5ee1f2b --- /dev/null +++ b/packages/json-document-react/docs/rename-session.md @@ -0,0 +1,5 @@ +# 초안 세션의 React 연결 + +`useRenameSession({owner, tryCommit, onFinish})`는 Affordance의 `createRenameSession`을 React에 연결합니다. `snapshot`으로 렌더링하고 `session.begin/update/commit/cancel`로 명령합니다. `tryCommit`이 false이면 초안을 닫지 않으며, owner가 바뀌면 이전 owner의 초안을 가져오지 않습니다. + +React owner는 관찰과 교체 수명을 소유하고, 초안 상태 전이는 Affordance가 소유합니다. [Sheet Usage](/demo/sheet)의 셀 편집과 Source에서 실제 연결을 확인할 수 있습니다. diff --git a/packages/json-document-react/package.json b/packages/json-document-react/package.json index 7e89e80b0..3fa4145fe 100644 --- a/packages/json-document-react/package.json +++ b/packages/json-document-react/package.json @@ -17,7 +17,13 @@ "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "LICENSE", + "docs" + ], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/json-document-react/src/index.ts b/packages/json-document-react/src/index.ts index f3befe462..fe9efc68d 100644 --- a/packages/json-document-react/src/index.ts +++ b/packages/json-document-react/src/index.ts @@ -94,3 +94,4 @@ export function useDocumentEditor( const [editor] = useState(() => createDocumentEditor(initial, options)); return editor; } +export { useRenameSession, type UseRenameSessionOptions, type RenameSessionBinding } from "./use-rename-session.js"; diff --git a/packages/json-document-react/src/use-rename-session.ts b/packages/json-document-react/src/use-rename-session.ts new file mode 100644 index 000000000..2199b4151 --- /dev/null +++ b/packages/json-document-react/src/use-rename-session.ts @@ -0,0 +1,30 @@ +import { useMemo, useRef, useSyncExternalStore } from "react"; +import { createRenameSession, type RenameSession, type RenameSessionSnapshot } from "@interactive-os/json-document-affordance"; + +export interface UseRenameSessionOptions { + /** Replacing the owner discards the old owner's draft. */ + readonly owner: object; + readonly tryCommit: (key: Key, draft: string) => boolean; + readonly onFinish?: (key: Key) => void; +} +export interface RenameSessionBinding { + readonly snapshot: RenameSessionSnapshot | null; + readonly session: RenameSession; +} + +/** Bind the canonical draft session to React observation and owner replacement. */ +export function useRenameSession(options: UseRenameSessionOptions): RenameSessionBinding { + const current = useRef(options); current.current = options; + const store = useMemo(() => { + const owner = options.owner; + const listeners = new Set<() => void>(); + const session = createRenameSession({ + tryCommit: (key, draft) => current.current.owner === owner && current.current.tryCommit(key, draft), + onFinish: key => {if (current.current.owner === owner) current.current.onFinish?.(key);}, + onSnapshot: () => listeners.forEach(listener => listener()), + }); + return {session, subscribe(listener: () => void) {listeners.add(listener); return () => {listeners.delete(listener);};}}; + }, [options.owner]); + const snapshot = useSyncExternalStore(store.subscribe, store.session.getSnapshot, store.session.getSnapshot); + return {snapshot, session: store.session}; +} diff --git a/packages/json-document-react/tests/use-rename-session.test.tsx b/packages/json-document-react/tests/use-rename-session.test.tsx new file mode 100644 index 000000000..27b06b461 --- /dev/null +++ b/packages/json-document-react/tests/use-rename-session.test.tsx @@ -0,0 +1,16 @@ +import {act, renderHook} from "@testing-library/react"; +import {expect, test, vi} from "vitest"; +import {useRenameSession} from "../src/use-rename-session.js"; +test("owner replacement discards the draft and stale sessions cannot commit to the new owner", () => { + const oldCommit=vi.fn(()=>false), newCommit=vi.fn(()=>true); + const {result,rerender}=renderHook(({owner,tryCommit})=>useRenameSession({owner,tryCommit}),{initialProps:{owner:{},tryCommit:oldCommit}}); + act(()=>result.current.session.begin("a","draft")); + act(()=>result.current.session.commit()); + expect(result.current.snapshot?.draft).toBe("draft"); + const oldSession=result.current.session; + rerender({owner:{},tryCommit:newCommit}); + expect(result.current.snapshot).toBeNull(); + act(()=>oldSession.commit()); expect(newCommit).not.toHaveBeenCalled(); + act(()=>result.current.session.begin("b","new")); act(()=>result.current.session.commit()); + expect(newCommit).toHaveBeenCalledWith("b","new"); expect(result.current.snapshot).toBeNull(); +}); diff --git a/packages/json-document-sheet/docs/api-reference.md b/packages/json-document-sheet/docs/api-reference.md index 4aedeaeba..0c7a931b7 100644 --- a/packages/json-document-sheet/docs/api-reference.md +++ b/packages/json-document-sheet/docs/api-reference.md @@ -17,7 +17,7 @@ SheetHand({ editor, label, headerRow, renderCell, onExit }: SheetHandProps): imp interface SheetHandProps { readonly editor: SheetEditor; readonly label?: string; - /** Markdown tables retain a mandatory header row and at least one column. */ + /** Header row presentation only; structure restrictions belong to editor.structure. */ readonly headerRow?: boolean; readonly onExit?: (edge: "before" | "after") => void; readonly renderCell?: (value: string) => ReactNode; diff --git a/packages/json-document-sheet/docs/editing.md b/packages/json-document-sheet/docs/editing.md index 4108f5968..d1ecfda21 100644 --- a/packages/json-document-sheet/docs/editing.md +++ b/packages/json-document-sheet/docs/editing.md @@ -16,8 +16,10 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: - Enter/F2/더블클릭: 편집. 입력 중 방향키는 문자열 안에서 이동. - 편집 중 Enter: 확정 후 아래 셀. Escape: 취소. Tab: 확정 후 다음 셀. - 복사/잘라내기/붙여넣기: Sheet의 정본 TSV 및 structured clipboard 계약. -- 행/열 추가·삭제: 단일 History transaction. `headerRow`에서는 첫 행과 마지막 열 삭제를 막습니다. +- 행/열 추가·삭제: 단일 History transaction. `editor.structure`의 capability가 헤더 행과 최소 열 수 제한을 제공합니다. `headerRow` prop은 제목 행의 표현만 결정합니다. 현재 Markdown 연결은 최상위 GFM 표에 적용됩니다. 셀의 inline Markdown을 원문으로 편집하며, 줄바꿈은 공백으로, 구분자 pipe는 escape하여 표 구조를 유지합니다. 수식 계산이나 파일 가져오기는 이 Hand의 기능이 아닙니다. 편집 여부에 관계없이 액션은 같은 위치의 아이콘으로 표시합니다. 입력창은 셀의 기존 글꼴·여백을 사용하며, 편집 진입·입력·취소는 표의 열 너비나 행 높이를 바꾸지 않습니다. 확정된 새 내용에 따른 표 크기 조정은 문서 변경으로 반영됩니다. + +편집 초안은 Affordance의 `createRenameSession`, React 관찰은 `useRenameSession`, 키의 의미는 `cellEditingAffordance`, 입력 UI는 `Field`를 사용합니다. 확정 거절 시 초안과 위치를 유지합니다. diff --git a/packages/json-document-sheet/src/sheet-hand.tsx b/packages/json-document-sheet/src/sheet-hand.tsx index b2fe41d2f..d5ade0348 100644 --- a/packages/json-document-sheet/src/sheet-hand.tsx +++ b/packages/json-document-sheet/src/sheet-hand.tsx @@ -1,15 +1,15 @@ import { Rows3, Columns3, Plus, Minus, Undo2, Redo2 } from "lucide-react"; import { useMemo, useRef, useState, type ReactNode, type KeyboardEvent } from "react"; import { jsonCellText, type SheetDocument, type SheetEditor, type GridPoint } from "@interactive-os/json-document-editing"; -import { editingItemProps, useEditingSnapshot, useGridEditing } from "@interactive-os/json-document-react"; -import { editingCommandFromWebKeyboardStroke } from "@interactive-os/json-document-affordance"; -import { createWebClipboardSurface, findWebGridCell, gridBoundary, moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, webGridCellAddressProps } from "@interactive-os/json-document-web"; -import { Command, Toolbar, GridCell } from "@interactive-os/json-document-ui-primitives-react"; +import { editingItemProps, useEditingSnapshot, useGridEditing, useRenameSession } from "@interactive-os/json-document-react"; +import { cellEditingAffordance, editingCommandFromWebKeyboardStroke } from "@interactive-os/json-document-affordance"; +import { isWebComposingKey, createWebClipboardSurface, findWebGridCell, gridBoundary, moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, webGridCellAddressProps } from "@interactive-os/json-document-web"; +import { Command, Toolbar, GridCell, Field } from "@interactive-os/json-document-ui-primitives-react"; export interface SheetHandProps { readonly editor: SheetEditor; readonly label?: string; - /** Markdown tables retain a mandatory header row and at least one column. */ + /** Header row presentation only; structure restrictions belong to editor.structure. */ readonly headerRow?: boolean; readonly onExit?: (edge: "before" | "after") => void; readonly renderCell?: (value: string) => ReactNode; @@ -20,8 +20,6 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, rend const snapshot = useEditingSnapshot(editor); const sheet = snapshot.value as SheetDocument; const surface = useRef(null); - const [draft, setDraft] = useState<{point: GridPoint; value: string} | null>(null); - const draftRef = useRef(draft); draftRef.current = draft; const [message, setMessage] = useState(""); const focus = snapshot.selection.focus; const topology = {rowIds: sheet.rows.map(row => row.id), columnIds: sheet.columns.map(column => column.id)}; @@ -36,39 +34,44 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, rend onDelete: () => report(editor.dispatch({type: "selection.fill", value: ""})), onUndo: () => report(editor.undo()), onRedo: () => report(editor.redo()), afterMove: focusCell}}); const clipboard = useMemo(() => createWebClipboardSurface({codec: sheetClipboardCodec, read: () => editor.copy(), cut: () => editor.cut()?.result ?? {ok: false, code: "selection.empty"}, paste: clipboard => editor.dispatch({type: "clipboard.paste", clipboard}), onResult: result => {if (!result.ok) setMessage(result.code);}}), [editor]); - const finish = () => { - const current = draftRef.current; - if (!current) return true; - draftRef.current = null; setDraft(null); - return report(editor.dispatch({type: "cell.commit", ...current.point, value: current.value})); - }; - const tab = (point: GridPoint, backward: boolean) => { - const index = topology.rowIds.indexOf(point.rowId) * topology.columnIds.length + topology.columnIds.indexOf(point.columnId) + (backward ? -1 : 1); - if (index < 0 || index >= sheet.rows.length * sheet.columns.length) {if (!onExit) return false; onExit(backward ? "before" : "after"); return true;} - select({rowId: topology.rowIds[Math.floor(index / sheet.columns.length)]!, columnId: topology.columnIds[index % sheet.columns.length]!}); return true; + const rename = useRenameSession({owner: editor, + tryCommit: (point, value) => report(editor.dispatch({type: "cell.commit", ...point, value})), + onFinish: focusCell, + }); + const draft = rename.snapshot; + const finish = () => {rename.session.commit(); return rename.session.getSnapshot() === null;}; + const move = (point: GridPoint, direction: "previous" | "next" | "up" | "down") => { + const next = moveGridPoint(topology, point, direction); + if (next) {select(next); return true;} + if ((direction === "previous" || direction === "next") && onExit) {onExit(direction === "previous" ? "before" : "after"); return true;} + return false; }; const keyDown = (event: KeyboardEvent) => { event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (draftRef.current) return; - if (!focus || event.target instanceof HTMLButtonElement) return; - if (event.key === "Tab") {if (tab(focus, event.shiftKey)) event.preventDefault(); return;} - if (event.key === "Enter" || event.key === "F2" || (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey)) { - event.preventDefault(); const cell = sheet.rows.find(row => row.id === focus.rowId)?.cells[focus.columnId]; - setDraft({point: focus, value: event.key.length === 1 ? event.key : jsonCellText(cell)}); return; - } - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {event.preventDefault(); editor.dispatch({type: "selection.select-all"}); return;} - editing.getKeyDownHandler()(event); + if (isWebComposingKey(event.nativeEvent)) return; + const current = rename.session.getSnapshot(); + const point = current?.key ?? focus; + if (!point || event.target instanceof HTMLButtonElement) return; + const hand = cellEditingAffordance(event, {editing: current !== null, allSelected: editor.selectedCells.length === sheet.rows.length * sheet.columns.length}).hand; + if (hand?.type === "rename") { + event.preventDefault(); + if (hand.action === "begin") rename.session.begin(point, hand.initialText ?? jsonCellText(sheet.rows.find(row => row.id === point.rowId)?.cells[point.columnId])); + else if (hand.action === "cancel") rename.session.cancel(); + else if (finish() && hand.move) move(point, hand.move); + } else if (hand?.type === "tab") { + if (!finish()) {event.preventDefault(); return;} + if (move(point, hand.direction === "prev" ? "previous" : "next")) event.preventDefault(); + } else if (hand?.type === "select-all") { + event.preventDefault(); editor.dispatch({type: "selection.select-all"}); + } else if (!current) editing.getKeyDownHandler()(event); }; - const rowIndex = sheet.rows.findIndex(row => row.id === focus?.rowId); - const columnIndex = sheet.columns.findIndex(column => column.id === focus?.columnId); - const nextId = (ids: readonly string[], prefix: string) => {let i = 1; while (ids.includes(`${prefix}-${i}`)) i++; return `${prefix}-${i}`;}; + const structure = editor.structure; return
event.stopPropagation()} onInput={event => event.stopPropagation()} onPointerDown={event => event.stopPropagation()}> - report(editor.dispatch({type: "row.insert", index: Math.max(headerRow ? 1 : 0, rowIndex + 1), row: {id: nextId(topology.rowIds, "row"), cells: Object.fromEntries(sheet.columns.map(column => [column.id, ""]))}}))}> - report(editor.dispatch({type: "column.insert", index: columnIndex + 1, column: {id: nextId(topology.columnIds, "column"), label: String.fromCharCode(65 + sheet.columns.length)}}))}> - focus && report(editor.dispatch({type: "row.delete", rowId: focus.rowId}))}> - focus && report(editor.dispatch({type: "column.delete", columnId: focus.columnId}))}> + report(editor.dispatch(structure.insertRow))}> + report(editor.dispatch(structure.insertColumn))}> + structure.deleteRow && report(editor.dispatch(structure.deleteRow))}> + structure.deleteColumn && report(editor.dispatch(structure.deleteColumn))}> report(editor.undo())}> report(editor.redo())}> @@ -77,26 +80,17 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, rend {sheet.columns.map(column => {column.label})} {sheet.rows.map((row, index) => {headerRow && index === 0 ? "제목" : index + (headerRow ? 0 : 1)}{sheet.columns.map(column => { const point = {rowId: row.id, columnId: column.id}; const item = editing.getCell(point); - const active = draft?.point.rowId === row.id && draft.point.columnId === column.id; + const active = draft?.key.rowId === row.id && draft.key.columnId === column.id; return setDraft({point, value: jsonCellText(row.cells[column.id])})}> + onDoubleClick={() => rename.session.begin(point, jsonCellText(row.cells[column.id]))}>
{(renderCell ? renderCell(jsonCellText(row.cells[column.id])) : jsonCellText(row.cells[column.id])) || }
- {active && event.currentTarget.select()} onPointerDown={event => event.stopPropagation()} - onChange={event => setDraft({point, value: event.target.value})} onBlur={finish} - onKeyDown={event => { - event.stopPropagation(); if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Escape") {event.preventDefault(); draftRef.current = null; setDraft(null); focusCell(point);} - else if (event.key === "Enter" || event.key === "Tab") { - if (event.key === "Enter") event.preventDefault(); - if (!finish()) return; - if (event.key === "Tab") {if (tab(point, event.shiftKey)) event.preventDefault();} - else {const next = moveGridPoint(topology, point, event.shiftKey ? "up" : "down"); select(next ?? point);} - } - }} />} + onValueChange={rename.session.update} onBlur={finish} + onKeyDown={keyDown} />}
; })})} diff --git a/packages/json-document-sheet/tests/sheet-hand.test.tsx b/packages/json-document-sheet/tests/sheet-hand.test.tsx index 0f1368fea..182c63f72 100644 --- a/packages/json-document-sheet/tests/sheet-hand.test.tsx +++ b/packages/json-document-sheet/tests/sheet-hand.test.tsx @@ -24,3 +24,19 @@ test("edit commits once, Escape cancels, structural commands undo", () => { fireEvent.click(screen.getByRole("button", {name:"실행 취소"})); expect((editor.snapshot.value as SheetDocument).columns).toHaveLength(1); }); + +test("a rejected commit retains the draft and does not navigate or close editing", () => { + const editor=createSheetEditor({columns:[{id:"a",label:"A"}],rows:[{id:"1",cells:{a:"one"}},{id:"2",cells:{a:"two"}}]}); + let reject=true; + const source={...editor,dispatch: ((intent) => intent.type === "cell.commit" && reject ? {ok:false,code:"cell.rejected"} : editor.dispatch(intent)) as typeof editor.dispatch}; + render(); + fireEvent.doubleClick(screen.getByText("one")); + fireEvent.change(screen.getByRole("textbox"),{target:{value:"retained"}}); + fireEvent.keyDown(screen.getByRole("textbox"),{key:"Tab"}); + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("retained"); + expect(editor.snapshot.selection.focus?.rowId).toBe("1"); + expect(screen.getByRole("status").textContent).toBe("cell.rejected"); + reject=false; fireEvent.keyDown(screen.getByRole("textbox"),{key:"Enter"}); + expect(screen.queryByRole("textbox")).toBeNull(); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("retained"); +}); diff --git a/packages/json-document-web/docs/api-reference.md b/packages/json-document-web/docs/api-reference.md index b661b693b..bb47a3645 100644 --- a/packages/json-document-web/docs/api-reference.md +++ b/packages/json-document-web/docs/api-reference.md @@ -164,6 +164,11 @@ focusWebItem(root: WebFocusItemRoot | null, ```ts gridBoundary(topology: GridTopology, point: GridPoint, edge: "start" | "end"): GridPoint | null ``` +## `isWebComposingKey` + +```ts +isWebComposingKey(event: { readonly isComposing?: boolean; readonly keyCode?: number; }): boolean +``` ## `isWebEditableTarget` ```ts @@ -748,6 +753,11 @@ interface WebKeyboardStroke { readonly altKey?: boolean; } ``` +## `webKeyboardText` + +```ts +webKeyboardText(stroke: WebKeyboardStroke): string | null +``` ## `WebKeymap` ```ts diff --git a/packages/json-document-web/docs/text-keys.md b/packages/json-document-web/docs/text-keys.md new file mode 100644 index 000000000..ba5f05346 --- /dev/null +++ b/packages/json-document-web/docs/text-keys.md @@ -0,0 +1,5 @@ +# Native 문자 키 경계 + +`isWebComposingKey(event)`는 `isComposing` 및 keyCode 229를 native IME 입력으로 구분합니다. `webKeyboardText(stroke)`는 shortcut과 제어 키를 제외한 문자 payload를 반환합니다. 편집 시작·확정 의미는 Affordance가 결정합니다. + +`moveGridPoint(topology, point, 'previous' | 'next')`는 기존 API로 행을 넘는 순차 셀 이동을 지원합니다. 끝에서는 null을 반환하며, 소비자가 문서 밖 이동이나 기본 Tab 흐름을 연결합니다. [Sheet Usage](/demo/sheet)는 별도의 좌표 계산 없이 이 계약을 사용합니다. diff --git a/packages/json-document-web/src/index.ts b/packages/json-document-web/src/index.ts index 2984aa0f1..10eb73ed0 100644 --- a/packages/json-document-web/src/index.ts +++ b/packages/json-document-web/src/index.ts @@ -52,6 +52,8 @@ export { rovingFocusItemProps, } from "./widget.js"; export { + isWebComposingKey, + webKeyboardText, chordFromStroke, createWebKeyboardAdapter, defaultWebKeymap, diff --git a/packages/json-document-web/src/keyboard.ts b/packages/json-document-web/src/keyboard.ts index f305fed75..28b1d62e2 100644 --- a/packages/json-document-web/src/keyboard.ts +++ b/packages/json-document-web/src/keyboard.ts @@ -158,3 +158,13 @@ function at(topology: GridTopology, rowIndex: number, columnIndex: number): Grid if (rowId === undefined || columnId === undefined) return null; return { rowId, columnId }; } + +/** Composition keys belong to the native text lease, including the legacy IME sentinel. */ +export function isWebComposingKey(event: {readonly isComposing?: boolean; readonly keyCode?: number}): boolean { + return event.isComposing === true || event.keyCode === 229; +} + +/** Printable key payload; shortcut chords and control keys do not author text. */ +export function webKeyboardText(stroke: WebKeyboardStroke): string | null { + return !stroke.metaKey && !stroke.ctrlKey && !stroke.altKey && Array.from(stroke.key).length === 1 && stroke.key >= " " ? stroke.key : null; +} diff --git a/packages/json-document-web/tests/text-keys.test.ts b/packages/json-document-web/tests/text-keys.test.ts new file mode 100644 index 000000000..219eaa324 --- /dev/null +++ b/packages/json-document-web/tests/text-keys.test.ts @@ -0,0 +1,16 @@ +import {expect, test} from "vitest"; +import {isWebComposingKey, webKeyboardText, moveGridPoint} from "../src/index.js"; +test("composition keys are native and only unmodified printable keys author text", () => { + expect(isWebComposingKey({isComposing:true})).toBe(true); + expect(isWebComposingKey({keyCode:229})).toBe(true); + expect(isWebComposingKey({keyCode:13})).toBe(false); + const stroke={key:"한",metaKey:false,ctrlKey:false,shiftKey:false}; + expect(webKeyboardText(stroke)).toBe("한"); expect(webKeyboardText({...stroke,key:"😀"})).toBe("😀"); + expect(webKeyboardText({...stroke,metaKey:true})).toBeNull(); expect(webKeyboardText({...stroke,key:"Enter"})).toBeNull(); +}); +test("sequential grid movement wraps rows and returns null at document edges", () => { + const grid={rowIds:["1","2"],columnIds:["a","b"]}; + expect(moveGridPoint(grid,{rowId:"1",columnId:"b"},"next")).toEqual({rowId:"2",columnId:"a"}); + expect(moveGridPoint(grid,{rowId:"2",columnId:"a"},"previous")).toEqual({rowId:"1",columnId:"b"}); + expect(moveGridPoint(grid,{rowId:"1",columnId:"a"},"previous")).toBeNull(); +}); diff --git a/site/scripts/check-canonical-module-closure.mjs b/site/scripts/check-canonical-module-closure.mjs index 3be3aaef1..b47d05c0d 100644 --- a/site/scripts/check-canonical-module-closure.mjs +++ b/site/scripts/check-canonical-module-closure.mjs @@ -10,6 +10,24 @@ const databasePropertyConsumers = [ "packages/json-document-database/src/database-hands.tsx", "packages/json-document-zod/src/database-document.ts", ]; +// Regression: moving a surface into a Hand must not retain its displaced owners. +const sheetHand = readSource("packages/json-document-sheet/src/sheet-hand.tsx"); +for (const [packageName, symbol] of [ + ["@interactive-os/json-document-react", "useRenameSession"], + ["@interactive-os/json-document-affordance", "cellEditingAffordance"], + ["@interactive-os/json-document-web", "moveGridPoint"], + ["@interactive-os/json-document-ui-primitives-react", "Field"], +]) { + if (!hasNamedImport(sheetHand, packageName, symbol)) throw new Error(`Sheet Hand bypasses ${symbol}`); +} +for (const pattern of [/ }> ; diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 2f253bc69..30cb57414 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,5 +1,8 @@ +import sheetStructureSource from "../../../../packages/json-document-editing/src/sheet-structure.ts?raw"; +import cellEditingSource from "../../../../packages/json-document-affordance/src/cell-editing.ts?raw"; +import renameSessionBindingSource from "../../../../packages/json-document-react/src/use-rename-session.ts?raw"; import sheetHandSource from "../../../../packages/json-document-sheet/src/sheet-hand.tsx?raw"; -import markdownTableEditorSource from "../../../../packages/json-document-markdown-react/src/markdown-table-editor.ts?raw"; +import markdownTableEditorSource from "../../../../packages/json-document-editing/src/markdown-table.ts?raw"; import markdownTableSource from "../../../../packages/json-document-markdown/src/table-editing.ts?raw"; import { siteRoutes } from "../../../route-registry.mjs"; import markdownSourceEditSource from "../../../../packages/json-document-markdown/src/source-edit.ts?raw"; @@ -213,8 +216,11 @@ const excludedSources = new Set([ "routes/widgets/WidgetDemoFrame.tsx", ]); const registeredUsageSources = new Map([ + ["packages/json-document-editing/src/sheet-structure.ts", sheetStructureSource], + ["packages/json-document-affordance/src/cell-editing.ts", cellEditingSource], + ["packages/json-document-react/src/use-rename-session.ts", renameSessionBindingSource], ["packages/json-document-sheet/src/sheet-hand.tsx", sheetHandSource], - ["packages/json-document-markdown-react/src/markdown-table-editor.ts", markdownTableEditorSource], + ["packages/json-document-editing/src/markdown-table.ts", markdownTableEditorSource], ["packages/json-document-markdown/src/table-editing.ts", markdownTableSource], ["packages/json-document-web/src/interaction-recording.ts", interactionRecordingSource], ["packages/json-document-web/src/interaction-recording-dom.ts", interactionRecordingDOMSource], @@ -401,7 +407,8 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredImplementationSources = new Map>([ - ["packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx", ["packages/json-document-markdown-react/src/markdown-table-editor.ts"]], + ["packages/json-document-editing/src/sheet.ts", ["packages/json-document-editing/src/sheet-structure.ts"]], + ["packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx", ["packages/json-document-editing/src/markdown-table.ts"]], ["packages/json-document-markdown/src/list-editing.ts", ["packages/json-document-markdown/src/source-edit.ts"]], ["packages/json-document-markdown/src/paragraph.ts", ["packages/json-document-markdown/src/list-editing.ts", "packages/json-document-markdown/src/source-edit.ts"]], ["routes/markdown-caret/MarkdownCaretRoute.tsx", ["app/interaction-recording/InteractionRecordingControls.tsx"]], @@ -410,15 +417,41 @@ const registeredImplementationSources = new Map>([ ["packages/json-document-database/src/database-hand.tsx", ["packages/json-document-database/src/database-property-control.tsx", "packages/json-document-database/src/database-view-controls.tsx"]], ]); const registeredPublicUsages = [ + { + packageName: "@interactive-os/json-document-editing", + symbol: "sheetColumnLabel", + sourcePath: "packages/json-document-editing/src/sheet-structure.ts", + }, + { + packageName: "@interactive-os/json-document-react", + symbol: "useRenameSession", + sourcePath: "packages/json-document-react/src/use-rename-session.ts", + }, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "cellEditingAffordance", + sourcePath: "packages/json-document-affordance/src/cell-editing.ts", + }, + { + packageName: "@interactive-os/json-document-web", + symbol: "isWebComposingKey", + sourcePath: "packages/json-document-web/src/keyboard.ts", + }, + { + packageName: "@interactive-os/json-document-web", + symbol: "webKeyboardText", + sourcePath: "packages/json-document-web/src/keyboard.ts", + }, + { packageName: "@interactive-os/json-document-sheet", symbol: "SheetHand", sourcePath: "packages/json-document-sheet/src/sheet-hand.tsx", }, { - packageName: "@interactive-os/json-document-markdown-react", + packageName: "@interactive-os/json-document-editing", symbol: "createMarkdownTableEditor", - sourcePath: "packages/json-document-markdown-react/src/markdown-table-editor.ts", + sourcePath: "packages/json-document-editing/src/markdown-table.ts", }, ...["readMarkdownTable", "replaceMarkdownTable", "markdownTableBoundary"].map(symbol => ({packageName: "@interactive-os/json-document-markdown", symbol, sourcePath: "packages/json-document-markdown/src/table-editing.ts"})), ...["createWebInteractionRecorder", "traceWebInteraction", "registerWebInteractionSource"].map(symbol => ({ packageName: "@interactive-os/json-document-web/interaction-recording", symbol, sourcePath: "packages/json-document-web/src/interaction-recording.ts" })), From e0d0773eb2e904e2799c2dfb0868e39eb62f68ae 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: Sun, 13 Sep 2026 14:04:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20Sheet=EC=99=80?= =?UTF-8?q?=20Bear=EC=9D=98=20=ED=91=9C=20=EC=83=81=ED=98=B8=EC=9E=91?= =?UTF-8?q?=EC=9A=A9=20=EC=A0=95=EB=B3=B8=20=EC=97=B0=EA=B2=B0=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture/modules.json | 11 +- integrations/dogfooding-sheet/README.md | 30 ++ integrations/dogfooding-sheet/migration.json | 52 +++ integrations/dogfooding-sheet/migration.patch | 300 ++++++++++++++++++ package-lock.json | 4 + .../docs/api-reference.md | 91 +++++- .../docs/grid-interaction.md | 15 + .../json-document-affordance/package.json | 2 + .../src/axis-resize.ts | 58 ++++ .../src/cell-editing.ts | 5 +- .../json-document-affordance/src/grid-fill.ts | 15 + .../json-document-affordance/src/index.ts | 5 + .../json-document-affordance/src/result.ts | 3 +- .../tests/grid-interaction.test.ts | 26 ++ .../docs/api-reference.md | 23 +- packages/json-document-editing/docs/sheet.md | 10 + packages/json-document-editing/src/index.ts | 2 + .../src/markdown-table.ts | 5 +- .../src/sheet-navigation.ts | 31 ++ packages/json-document-editing/src/sheet.ts | 67 +++- .../tests/sheet-navigation.test.ts | 48 +++ .../docs/api-reference.md | 5 + .../docs/editing.md | 6 + .../json-document-markdown-react/package.json | 2 + .../src/MarkdownCellEditor.tsx | 25 ++ .../src/MarkdownEditingSurface.tsx | 5 +- .../json-document-markdown-react/src/index.ts | 1 + .../tsconfig.json | 1 + .../docs/api-reference.md | 5 +- .../src/editing-binding.ts | 5 +- .../src/markdown-dom.ts | 4 +- .../tests/markdown-dom.test.ts | 10 + .../docs/api-reference.md | 25 ++ .../docs/grid-traversal.md | 11 + packages/json-document-selection/package.json | 2 +- packages/json-document-selection/src/index.ts | 2 + .../src/interaction/grid-traversal.ts | 31 ++ .../tests/grid-traversal.test.ts | 20 ++ .../json-document-sheet/docs/api-reference.md | 18 +- packages/json-document-sheet/docs/editing.md | 12 +- packages/json-document-sheet/package.json | 2 + packages/json-document-sheet/src/index.ts | 2 +- .../src/sheet-axis-resize.tsx | 26 ++ .../src/sheet-fill-handle.tsx | 29 ++ .../json-document-sheet/src/sheet-hand.tsx | 70 +++- .../src/sheet-range-selection.tsx | 44 +++ packages/json-document-sheet/tsconfig.json | 1 + .../docs/api-reference.md | 2 + .../src/surfaces.tsx | 7 +- .../json-document-web/docs/api-reference.md | 5 + packages/json-document-web/src/grid-cell.ts | 12 + packages/json-document-web/src/index.ts | 1 + packages/json-document-web/src/keyboard.ts | 13 +- scripts/apply-sheet-sibling-migration.mjs | 22 ++ site/site-routes.json | 8 +- site/src/routes/sheet-demo/SheetDemo.tsx | 5 +- .../src/shared/demo-workbench/demo-sources.ts | 59 +++- site/tests/browser/sheet-demo.spec.ts | 46 ++- site/tests/unit/demo-workbench.test.tsx | 6 + 59 files changed, 1297 insertions(+), 56 deletions(-) create mode 100644 integrations/dogfooding-sheet/README.md create mode 100644 integrations/dogfooding-sheet/migration.json create mode 100644 integrations/dogfooding-sheet/migration.patch create mode 100644 packages/json-document-affordance/docs/grid-interaction.md create mode 100644 packages/json-document-affordance/src/axis-resize.ts create mode 100644 packages/json-document-affordance/src/grid-fill.ts create mode 100644 packages/json-document-affordance/tests/grid-interaction.test.ts create mode 100644 packages/json-document-editing/src/sheet-navigation.ts create mode 100644 packages/json-document-editing/tests/sheet-navigation.test.ts create mode 100644 packages/json-document-markdown-react/src/MarkdownCellEditor.tsx create mode 100644 packages/json-document-selection/docs/grid-traversal.md create mode 100644 packages/json-document-selection/src/interaction/grid-traversal.ts create mode 100644 packages/json-document-selection/tests/grid-traversal.test.ts create mode 100644 packages/json-document-sheet/src/sheet-axis-resize.tsx create mode 100644 packages/json-document-sheet/src/sheet-fill-handle.tsx create mode 100644 packages/json-document-sheet/src/sheet-range-selection.tsx create mode 100644 scripts/apply-sheet-sibling-migration.mjs diff --git a/architecture/modules.json b/architecture/modules.json index 1d5ea61d2..c720af9e4 100644 --- a/architecture/modules.json +++ b/architecture/modules.json @@ -248,7 +248,16 @@ ], "responsibility": "선택·이동·취소 등 입력 독립 조작 의미와 수명", "entrypoint": "packages/json-document-affordance/src/index.ts", - "subpaths": [], + "subpaths": [ + { + "packageName": "@interactive-os/json-document-affordance/axis-resize", + "entrypoint": "packages/json-document-affordance/src/axis-resize.ts" + }, + { + "packageName": "@interactive-os/json-document-affordance/grid-fill", + "entrypoint": "packages/json-document-affordance/src/grid-fill.ts" + } + ], "referencePath": "packages/json-document-affordance/docs/api-reference.md" }, { diff --git a/integrations/dogfooding-sheet/README.md b/integrations/dogfooding-sheet/README.md new file mode 100644 index 000000000..524d4052e --- /dev/null +++ b/integrations/dogfooding-sheet/README.md @@ -0,0 +1,30 @@ +# 기존 Sheet 연결분 + +이 패치는 `developer-1px/dogfooding-sheet`의 로컬 `spredsheet` 작업 상태에 적용한 변경만 담습니다. 시작 HEAD는 `fb31a2e`였지만 411개 미커밋 변경 항목이 있었으므로 HEAD와의 diff가 아니라 작업 시작 시 파일 내용과의 diff입니다. 기존 작업을 별도 커밋하거나 다른 브랜치로 덮어쓰지 않았습니다. + +정본 연결: + +- `rangeTabTarget` / `rangeEnterTarget` → Selection `traverseGrid` +- `useSheetGrid` 선택 드래그 상태 → Selection `reducePressInteraction` +- `fillTargetForCell` → Affordance `/grid-fill`의 `extendGridFill` +- `resizeRules` → Affordance `/axis-resize`의 크기 제한·키보드·접기/복원 + +A1 좌표, 보이는 축의 희소 투영, 수식·수열 채우기와 제품 기본 크기는 기존 Sheet에 유지합니다. 의존성 가드는 임의 runtime을 허용하도록 제거하지 않고, 위 정본 package/subpath만 허용합니다. + +```sh +node scripts/apply-sheet-sibling-migration.mjs /path/to/spredsheet +# 아직 적용 전이고 모든 baseline SHA가 맞을 때에만: +node scripts/apply-sheet-sibling-migration.mjs /path/to/spredsheet --apply +``` + +`migration.json`은 변경 전후 SHA-256을 기록합니다. 다른 작업이 겹치거나 일부만 적용된 상태에서는 아무 파일도 변경하지 않습니다. 이 패치를 현재 Git HEAD에 바로 적용하는 것은 지원하지 않습니다. 해당 미커밋 작업을 먼저 포함한 뒤 적용하거나, 그 작업이 정리된 시점에 다시 대조해야 합니다. + +이번 json-document revision을 빌드한 Selection/Affordance package가 필요합니다. 로컬 검증에서는 형제 앱의 기존 설치를 보존하고 두 package만 `node_modules/@interactive-os/`에서 이 작업의 빌드로 연결했습니다. 재현 환경은 이 revision의 `npm pack` 결과를 설치하면 됩니다. package manifest의 버전 범위만으로 아직 배포되지 않은 새 export가 설치된다고 가정하지 않습니다. 레거시 의존 경로를 대량 갱신하거나 형제 lockfile 전체를 재생성하지 않았습니다. + +검증 명령은 형제 repo root에서 실행합니다: + +```sh +./node_modules/.bin/vitest run src/app/previews/keyboard-anchor-preview.test.ts src/widgets/sheet-grid/hooks/useSheetGrid.react.test.ts src/widgets/sheet-grid/model/resizeRules.test.ts src/widgets/sheet-grid/ui/GridHeader.test.ts src/widgets/sheet-grid/ui/RowHeader.test.ts src/features/fill/model/fillDown.test.ts src/features/fill/hooks/useAutoFill.test.ts --maxWorkers=1 +``` + +Grid package 검증은 `packages/grid`를 cwd로 하여 `../../node_modules/.bin/vitest run --config vitest.config.ts`를 실행합니다. root에서 이 config만 지정하면 include 경로가 root에 적용돼 의도치 않게 전체 앱 테스트를 실행하므로 cwd를 구분해야 합니다. diff --git a/integrations/dogfooding-sheet/migration.json b/integrations/dogfooding-sheet/migration.json new file mode 100644 index 000000000..7a1a99112 --- /dev/null +++ b/integrations/dogfooding-sheet/migration.json @@ -0,0 +1,52 @@ +{ + "repository": "developer-1px/dogfooding-sheet", + "baselineHead": "fb31a2e", + "baselineIncludesUncommittedWork": true, + "files": [ + { + "path": "package.json", + "before": "4cbe6f212a21a5dcb63cdf5e5611cc3aa7902f58664fd56784df824595aec24c", + "after": "86f09f367d7cc2b80da51fef8deae55a4d71d93f7ea6dc285ad0d7d40ce9443f" + }, + { + "path": "packages/editable-grid/package.json", + "before": "74ccb063439140488299c3c54c21251b51ee312cd8cc1729a41f3b5147d7db12", + "after": "be1e339c439b0decb9516288a2d277a35084b6b35cf4007803075a2b89f94302" + }, + { + "path": "packages/editable-grid/src/resizeRules.ts", + "before": "89205de231f79cc3962fd40d04480d68a8f2b3c438e3d61a69e36e878c69df93", + "after": "d2891d1791904701ce44f2c3306e33e62db41bfbd9162a06407eb38e5da644bb" + }, + { + "path": "packages/grid/README.md", + "before": "9ef54a610c0393a78e6ddd6d71bd05bbd917d408b77d90240a339c753272486f", + "after": "1c9bb082fe919796b4184339c620036d17cedf8f87f4607be0335a5e5429c498" + }, + { + "path": "packages/grid/package.json", + "before": "3cd9ec2880f2b9b59f5dfe3651a3c8c1424e4100a8dc04d3f37b24eefb540e0c", + "after": "3e227c7a7db92af8a10b1597b490fe2700d2079e56de0d8f259ae7a8e0dabb11" + }, + { + "path": "packages/grid/src/editing/fillHandle.ts", + "before": "0769548387ced0f291877d72e6f6e647ba2fe564b7f0d7f8f3bfbcab32b19243", + "after": "a5aa724890cd4a1b7715a7d5a44432549366dbd5dab3e932db5b70f8d6511ef4" + }, + { + "path": "packages/grid/src/purity.test.ts", + "before": "0871b6158d8808017a05a56e66ab1b67db06f750209ea971170073795a4847f1", + "after": "338cc3e59e7a75332fbe156a6168ace4dd4f621dc1eafca75ea759665e549bc7" + }, + { + "path": "packages/grid/src/selection/navigation.ts", + "before": "edda5dbc96e44437bbd3166df5231b9d3f12d61d0675673fa745a03a915eec03", + "after": "80b82858fa95e17588863283adb9096a1eaeedfec5661f0ead434a620f790eed" + }, + { + "path": "src/widgets/sheet-grid/hooks/useSheetGrid.ts", + "before": "570bec27f6ac627a992c7183abc8216280c513b2a4df8f88bf734fcea8cc5fdd", + "after": "1488a1faa27295846ab30ffd54885c03c90e95f4feda100745a8b6291d668f6e" + } + ] +} diff --git a/integrations/dogfooding-sheet/migration.patch b/integrations/dogfooding-sheet/migration.patch new file mode 100644 index 000000000..688646a41 --- /dev/null +++ b/integrations/dogfooding-sheet/migration.patch @@ -0,0 +1,300 @@ +--- a/package.json ++++ b/package.json +@@ -12,6 +12,7 @@ + "check": "pnpm lint && pnpm build && pnpm test" + }, + "dependencies": { ++ "@interactive-os/json-document-selection": "^0.1.0-rc.0", + "@interactive-os/editable": "file:../editable/packages/editable", + "@interactive-os/aria": "file:../apg-patterns", + "@interactive-os/aria-kernel": "file:../aria-kernel/legacy/aria-kernel", +--- a/packages/editable-grid/package.json ++++ b/packages/editable-grid/package.json +@@ -48,6 +48,7 @@ + "zod": "^4.0.0" + }, + "dependencies": { ++ "@interactive-os/json-document-affordance": "^0.1.0-rc.0", + "@interactive-os/aria": "file:../../../apg-patterns", + "@spredsheet/grid": "workspace:*" + }, +--- a/packages/editable-grid/src/resizeRules.ts ++++ b/packages/editable-grid/src/resizeRules.ts +@@ -1,62 +1,8 @@ +-export interface ResizeBounds { +- min: number +- max?: number +-} ++import type { ResizeBounds } from '@interactive-os/json-document-affordance/axis-resize' ++export { clampResizeValue, storedResizeValue, resizeValueForKey, collapseResizeValue } from '@interactive-os/json-document-affordance/axis-resize' ++export type { ResizeBounds } from '@interactive-os/json-document-affordance/axis-resize' + + export const DEFAULT_COLUMN_WIDTH = 100 + export const DEFAULT_ROW_HEIGHT = 28 + export const COLUMN_WIDTH_BOUNDS = { min: 40, max: 400 } satisfies ResizeBounds + export const ROW_HEIGHT_BOUNDS = { min: 18, max: 1000 } satisfies ResizeBounds +- +-const STEP = 10 +-const LARGE_STEP = 50 +- +-export function clampResizeValue(value: number, bounds: ResizeBounds): number { +- const finite = Number.isFinite(value) ? value : bounds.min +- const minClamped = Math.max(bounds.min, finite) +- return bounds.max === undefined ? minClamped : Math.min(bounds.max, minClamped) +-} +- +-export function storedResizeValue(value: number, bounds: ResizeBounds): number { +- return Math.round(clampResizeValue(value, bounds)) +-} +- +-export function resizeValueForKey( +- current: number, +- key: string, +- shiftKey: boolean, +- axis: 'x' | 'y', +- bounds: ResizeBounds, +-): number | null { +- const step = shiftKey ? LARGE_STEP : STEP +- if (key === 'PageUp') return clampResizeValue(current + LARGE_STEP, bounds) +- if (key === 'PageDown') return clampResizeValue(current - LARGE_STEP, bounds) +- if (key === 'Home') return bounds.min +- if (key === 'End') return bounds.max ?? null +- +- const delta = +- axis === 'x' +- ? key === 'ArrowLeft' ? -step : key === 'ArrowRight' ? step : null +- : key === 'ArrowUp' ? -step : key === 'ArrowDown' ? step : null +- +- return delta === null ? null : clampResizeValue(current + delta, bounds) +-} +- +-export function collapseResizeValue( +- current: number, +- previous: number | null, +- bounds: ResizeBounds, +- defaultValue: number, +-): { value: number; previous: number | null } { +- const normalizedCurrent = clampResizeValue(current, bounds) +- if (normalizedCurrent <= bounds.min) { +- return { +- value: clampResizeValue(previous ?? defaultValue, bounds), +- previous: null, +- } +- } +- return { +- value: bounds.min, +- previous: normalizedCurrent, +- } +-} +--- a/packages/grid/README.md ++++ b/packages/grid/README.md +@@ -1,6 +1,6 @@ + # @spredsheet/grid + +-Headless grid editing engine with no runtime dependencies. ++Headless grid editing engine using canonical headless selection and fill contracts. + + Scope: + +@@ -23,7 +23,7 @@ + + Independence contract: + +-- no runtime dependencies ++- runtime dependencies are limited to canonical Selection and the DOM-free Affordance grid-fill entrypoint + - no React or DOM access + - no system clipboard access + - no app-specific imports +@@ -49,3 +49,7 @@ + - system clipboard calls + - aria-kernel/APG behavior + - json-document patches/history ++ ++## Shared interaction owners ++ ++`rangeTabTarget` / `rangeEnterTarget` translate A1 and visible-axis coordinates to `traverseGrid` from `@interactive-os/json-document-selection`. `fillTargetForCell` uses `extendGridFill` from `@interactive-os/json-document-affordance/grid-fill`. Bear and this spreadsheet therefore share traversal order and fill-axis decisions while retaining format-specific writes, formulas, and history. +--- a/packages/grid/package.json ++++ b/packages/grid/package.json +@@ -1,7 +1,7 @@ + { + "name": "@spredsheet/grid", + "version": "0.0.0", +- "description": "Headless grid editing engine with no runtime dependencies.", ++ "description": "Headless grid editing engine using canonical selection traversal.", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", +@@ -23,6 +23,10 @@ + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, ++ "dependencies": { ++ "@interactive-os/json-document-affordance": "^0.1.0-rc.0", ++ "@interactive-os/json-document-selection": "^0.1.0-rc.0" ++ }, + "devDependencies": { + "typescript": "~6.0.2", + "vitest": "^4.1.5" +--- a/packages/grid/src/editing/fillHandle.ts ++++ b/packages/grid/src/editing/fillHandle.ts +@@ -1,3 +1,4 @@ ++import { extendGridFill } from '@interactive-os/json-document-affordance/grid-fill' + import { cellId, cellKey, colIndex, columnLabel, parseCellId, type Cells } from '../coordinates/a1' + import { idsInRect, rectFromIds, rectOfCell, type Rect } from '../geometry/rect' + +@@ -27,22 +28,7 @@ + ): Rect | null { + const p = parseCellId(targetCellId) + if (!p) return null +- const ci = colIndex(p.col) +- const down = p.row > source.rMax ? p.row - source.rMax : 0 +- const up = p.row < source.rMin ? source.rMin - p.row : 0 +- const right = ci > source.cMax ? ci - source.cMax : 0 +- const left = ci < source.cMin ? source.cMin - ci : 0 +- if (down === 0 && up === 0 && right === 0 && left === 0) return source +- const vertical = Math.max(up, down) +- const horizontal = Math.max(left, right) +- if (vertical >= horizontal) { +- return up > down +- ? { ...source, rMin: Math.max(0, p.row) } +- : { ...source, rMax: Math.min(bounds.rowCount - 1, p.row) } +- } +- return left > right +- ? { ...source, cMin: Math.max(0, ci) } +- : { ...source, cMax: Math.min(bounds.colLetters.length - 1, ci) } ++ return extendGridFill(source, { row: p.row, column: colIndex(p.col) }, { rowCount: bounds.rowCount, columnCount: bounds.colLetters.length }) + } + + export function idsInFillTarget(target: Rect, colLetters: readonly string[]): string[] { +--- a/packages/grid/src/purity.test.ts ++++ b/packages/grid/src/purity.test.ts +@@ -13,14 +13,16 @@ + }) + + describe('@spredsheet/grid package boundary', () => { +- it('has no runtime dependencies', () => { ++ it('depends only on canonical headless interaction contracts', () => { + const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as { + private?: boolean + dependencies?: Record + peerDependencies?: Record + } + expect(pkg.private).not.toBe(true) +- expect(pkg.dependencies ?? {}).toEqual({}) ++ expect(Object.keys(pkg.dependencies ?? {}).sort()).toEqual([ ++ '@interactive-os/json-document-affordance', '@interactive-os/json-document-selection', ++ ]) + expect(pkg.peerDependencies ?? {}).toEqual({}) + }) + +@@ -37,7 +39,7 @@ + ] + + const offenders = sourceFiles(root).flatMap((file) => { +- const text = readFileSync(file, 'utf8') ++ const text = readFileSync(file, 'utf8').replace(/^import .+ from ['"]@interactive-os\/json-document-(?:selection|affordance\/grid-fill)['"]\r?\n/gm, '') + return forbidden.some((pattern) => pattern.test(text)) ? [relative(packageRoot, file)] : [] + }) + +--- a/packages/grid/src/selection/navigation.ts ++++ b/packages/grid/src/selection/navigation.ts +@@ -1,3 +1,4 @@ ++import { traverseGrid } from '@interactive-os/json-document-selection' + import { COL_LETTERS, cellId, cellKey, colIndex, parseCellId, type Cells } from '../coordinates/a1' + import { idsInRect, rectFromRefs } from '../geometry/rect' + import { selectionRangesFromNumbers, selectionShapeForIds, tagSelectionShape, type SelectionAxisRange } from './metadata' +@@ -443,13 +444,13 @@ + const rowIndex = shape.rows.indexOf(ref.row) + const colIndex = shape.cols.indexOf(ref.col) + if (rowIndex < 0 || colIndex < 0) return null +- const colCount = shape.cols.length +- const cellCount = shape.rows.length * colCount +- const offset = rowIndex * colCount + colIndex +- const nextOffset = (offset + (shift ? -1 : 1) + cellCount) % cellCount +- const nextRow = shape.rows.at(Math.floor(nextOffset / colCount)) +- const nextCol = shape.cols.at(nextOffset % colCount) +- return nextRow !== undefined && nextCol ? cellId(nextCol, nextRow) : null ++ const next = traverseGrid({ rowIndex, columnIndex: colIndex }, { ++ rowCount: shape.rows.length, columnCount: shape.cols.length, ++ order: 'row-major', reverse: shift, wrap: true, ++ }) ++ const nextRow = next && shape.rows.at(next.rowIndex) ++ const nextCol = next && shape.cols.at(next.columnIndex) ++ return nextRow !== null && nextRow !== undefined && nextCol ? cellId(nextCol, nextRow) : null + } + + const enterTarget = ( +@@ -484,12 +485,13 @@ + const rowIndex = shape.rows.indexOf(ref.row) + const colIndex = shape.cols.indexOf(ref.col) + if (rowIndex < 0 || colIndex < 0) return null +- const cellCount = shape.rows.length * shape.cols.length +- const offset = colIndex * shape.rows.length + rowIndex +- const nextOffset = (offset + (shift ? -1 : 1) + cellCount) % cellCount +- const nextCol = shape.cols.at(Math.floor(nextOffset / shape.rows.length)) +- const nextRow = shape.rows.at(nextOffset % shape.rows.length) +- return nextRow !== undefined && nextCol ? cellId(nextCol, nextRow) : null ++ const next = traverseGrid({ rowIndex, columnIndex: colIndex }, { ++ rowCount: shape.rows.length, columnCount: shape.cols.length, ++ order: 'column-major', reverse: shift, wrap: true, ++ }) ++ const nextRow = next && shape.rows.at(next.rowIndex) ++ const nextCol = next && shape.cols.at(next.columnIndex) ++ return nextRow !== null && nextRow !== undefined && nextCol ? cellId(nextCol, nextRow) : null + } + + export function homeEndTarget( +--- a/src/widgets/sheet-grid/hooks/useSheetGrid.ts ++++ b/src/widgets/sheet-grid/hooks/useSheetGrid.ts +@@ -1,3 +1,4 @@ ++import { idlePointerInteraction, reducePressInteraction, type PointerInteractionState } from '@interactive-os/json-document-selection' + import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + import { gridDefinition, reducePatternData, type Key, type PatternData, type PatternEvent, type PatternEventReason } from '@interactive-os/aria' + import { useGridPattern } from '@interactive-os/aria/react' +@@ -834,8 +835,7 @@ + } + + export function useSheetGrid({ data, setFocusId, setSelectedIds, setSelectAnchor, startEdit, isEditing, projectedRowIds, projectedColumnIds }: Args) { +- const dragAnchor = useRef(null) +- const dragging = useRef(false) ++ const drag = useRef>(idlePointerInteraction()) + const suppressNextSelect = useRef(false) + const cellHandlersById = useRef(new Map()) + const renderCache = useMemo(() => new SheetGridRenderCache(), []) +@@ -843,7 +843,10 @@ + const hasRenderProjection = projectedRowIds !== undefined || projectedColumnIds !== undefined + + useEffect(() => { +- const stopDrag = () => { dragging.current = false } ++ const stopDrag = () => { ++ const current = drag.current ++ if (current.kind === 'active') drag.current = reducePressInteraction(current, { phase: 'end', pointerId: current.pointerId, point: current.current }).state ++ } + window.addEventListener('mouseup', stopDrag) + return () => window.removeEventListener('mouseup', stopDrag) + }, []) +@@ -1084,18 +1087,18 @@ + if (event.ctrlKey || event.metaKey) { + event.preventDefault() + suppressNextSelect.current = true +- dragging.current = false +- dragAnchor.current = null ++ drag.current = idlePointerInteraction() + toggleCell(id) + return + } +- dragging.current = true +- dragAnchor.current = id ++ drag.current = reducePressInteraction(idlePointerInteraction(), { phase: 'start', pointerId: 'mouse', point: id, operation: 'replace' }).state + selectCell(id) + }, + onMouseEnter: () => { +- if (!dragging.current || !dragAnchor.current) return +- selectRange(dragAnchor.current, id) ++ const current = drag.current ++ const result = reducePressInteraction(current, { phase: 'move', pointerId: 'mouse', point: id }) ++ drag.current = result.state ++ if (current.kind === 'active' && result.preview) selectRange(current.start, result.preview.point) + }, + } + cellHandlersById.current.set(id, handlers) diff --git a/package-lock.json b/package-lock.json index 6e27b3b9e..769256a47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6873,6 +6873,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@interactive-os/json-document": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-markdown": "*", "@interactive-os/json-document-markdown-web": "*", @@ -6888,6 +6889,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-editing": "^0.1.0-rc.0", "@interactive-os/json-document-markdown": "^0.1.0-rc.0", "@interactive-os/json-document-markdown-web": "^0.1.0-rc.0", @@ -7150,6 +7152,7 @@ "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-react": "*", + "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-ui-primitives-react": "*", "@interactive-os/json-document-web": "*", "@testing-library/react": "^16.3.2", @@ -7165,6 +7168,7 @@ "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-selection": "^0.1.0-rc.0", "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", "react": "^18.0.0 || ^19.0.0" diff --git a/packages/json-document-affordance/docs/api-reference.md b/packages/json-document-affordance/docs/api-reference.md index e4bc4c6d9..6e01ffb47 100644 --- a/packages/json-document-affordance/docs/api-reference.md +++ b/packages/json-document-affordance/docs/api-reference.md @@ -38,6 +38,7 @@ type AffordanceHand = readonly operation: SelectOperation; readonly rect?: AffordanceRect; readonly objectIds?: ReadonlyArray; + readonly axis?: "row" | "column"; } | { readonly type: "move"; readonly direction: AffordanceMoveDirection; readonly operation: "replace" | "extend" } | { readonly type: "boundary"; readonly edge: "start" | "end"; readonly operation: "replace" | "extend" } @@ -69,7 +70,7 @@ type AffordanceHand = readonly edge?: "start" | "end"; readonly operation: "replace" | "extend"; } - | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel"; readonly initialText?: string; readonly move?: "up" | "down" } + | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel"; readonly initialText?: string; readonly move?: "up" | "down"; readonly target?: "selection" } | { readonly type: "activate" } | { readonly type: "press"; @@ -244,13 +245,23 @@ caretCursor(direction: "horizontal" | "vertical"): "text" | "vertical-text" ## `cellEditingAffordance` ```ts -cellEditingAffordance(stroke: WebKeyboardStroke, state: { readonly editing: boolean; readonly allSelected: boolean; }): AffordancePreview +cellEditingAffordance(stroke: WebKeyboardStroke, state: { readonly editing: boolean; readonly allSelected: boolean; readonly enter?: "edit" | "move"; }): AffordancePreview +``` +## `clampResizeValue` + +```ts +clampResizeValue(value: number, bounds: ResizeBounds): number ``` ## `clickCountAffordance` ```ts clickCountAffordance(detail: number): AffordancePreview ``` +## `collapseResizeValue` + +```ts +collapseResizeValue(current: number, previous: number | null, bounds: ResizeBounds, defaultValue: number): { value: number; previous: number | null; } +``` ## `commitAffordance` ```ts @@ -432,6 +443,11 @@ editingCommandFromWebKeyboardStroke(stroke: WebKeyboardStroke): WebKeyboardComma ```ts escapeAffordance(input: { readonly key?: string; readonly type?: string; readonly grabbing?: boolean; readonly selected?: boolean; }): AffordancePreview ``` +## `extendGridFill` + +```ts +extendGridFill(source: GridFillBounds, point: { readonly row: number; readonly column: number; }, bounds: { readonly rowCount: number; readonly columnCount: number; }): GridFillBounds +``` ## `FloatingAlignment` ```ts @@ -532,6 +548,13 @@ interface GestureState { readonly type: string; } ``` +## `GridFillBounds` + +```ts +interface GridFillBounds { + readonly rMin: number; readonly rMax: number; readonly cMin: number; readonly cMax: number; +} +``` ## `historyAffordance` ```ts @@ -862,6 +885,14 @@ interface RenameSessionSnapshot { ```ts resizeAffordance(origin: Point, point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean; }, size?: Pick): AffordancePreview ``` +## `ResizeBounds` + +```ts +interface ResizeBounds { + min: number + max?: number +} +``` ## `ResizeEdge` ```ts @@ -876,6 +907,11 @@ type ResizeHandleDescriptor = { readonly cursor?: InteractionHandleCursorPolicy; }; ``` +## `resizeValueForKey` + +```ts +resizeValueForKey(current: number, key: string, shiftKey: boolean, axis: "x" | "y", bounds: ResizeBounds): number | null +``` ## `resolveAffordanceKey` ```ts @@ -896,6 +932,11 @@ type SelectOperation = "replace" | "extend" | "toggle"; ```ts snapAffordance(point: Point, options: { readonly grid: number; readonly disable?: boolean; }): AffordancePreview ``` +## `storedResizeValue` + +```ts +storedResizeValue(value: number, bounds: ResizeBounds): number +``` ## `treeAffordance` ```ts @@ -1028,3 +1069,49 @@ wheelAffordance(input: { readonly deltaX?: number; readonly deltaY?: number; rea ```ts zoomAffordance(input: { readonly key?: string; }): AffordancePreview ``` +## `@interactive-os/json-document-affordance/axis-resize` + +아래 API는 package root가 아닌 이 subpath에서 import합니다. +### `clampResizeValue` + +```ts +clampResizeValue(value: number, bounds: ResizeBounds): number +``` +### `collapseResizeValue` + +```ts +collapseResizeValue(current: number, previous: number | null, bounds: ResizeBounds, defaultValue: number): { value: number; previous: number | null; } +``` +### `ResizeBounds` + +```ts +interface ResizeBounds { + min: number + max?: number +} +``` +### `resizeValueForKey` + +```ts +resizeValueForKey(current: number, key: string, shiftKey: boolean, axis: "x" | "y", bounds: ResizeBounds): number | null +``` +### `storedResizeValue` + +```ts +storedResizeValue(value: number, bounds: ResizeBounds): number +``` +## `@interactive-os/json-document-affordance/grid-fill` + +아래 API는 package root가 아닌 이 subpath에서 import합니다. +### `extendGridFill` + +```ts +extendGridFill(source: GridFillBounds, point: { readonly row: number; readonly column: number; }, bounds: { readonly rowCount: number; readonly columnCount: number; }): GridFillBounds +``` +### `GridFillBounds` + +```ts +interface GridFillBounds { + readonly rMin: number; readonly rMax: number; readonly cMin: number; readonly cMax: number; +} +``` diff --git a/packages/json-document-affordance/docs/grid-interaction.md b/packages/json-document-affordance/docs/grid-interaction.md new file mode 100644 index 000000000..8d0929087 --- /dev/null +++ b/packages/json-document-affordance/docs/grid-interaction.md @@ -0,0 +1,15 @@ +# Grid fill and axis resize + +`extendGridFill(source, point, bounds)`는 셀 범위 채우기의 방향을 결정합니다. 시작 사각형에서 더 멀리 벗어난 축을 택하고, 동률이면 세로 축을 택하며, 표 경계로 제한합니다. 셀 값 복제·수식 이동·History는 이 함수의 책임이 아닙니다. + +`clampResizeValue`, `storedResizeValue`, `resizeValueForKey`, `collapseResizeValue`는 한 축의 크기 제한·저장 시 반올림·키보드 증감·접기/복원을 제공합니다. 기본 폭과 최소/최대 크기는 소비자가 정책으로 제공합니다. 일반 증감은 10, Shift 증감과 PageUp/Down은 50이며 Home/End는 경계 크기를 선택합니다. + +두 계약은 형제 `dogfooding-sheet`에서 가져와 정본으로 옮겼습니다. 형제의 `fillTargetForCell`과 `resizeRules`는 좌표 변환과 제품 기본값을 유지하며 이 API를 소비합니다. 작은 엔진이 Web/React나 다른 runtime을 로드하지 않도록 `@interactive-os/json-document-affordance/grid-fill` 및 `/axis-resize` subpath도 제공합니다. package root와 같은 구현입니다. + +```ts +import { extendGridFill, storedResizeValue } from '@interactive-os/json-document-affordance'; +const target=extendGridFill({rMin:0,rMax:1,cMin:0,cMax:1},{row:3,column:1},{rowCount:10,columnCount:5}); +const width=storedResizeValue(143.6,{min:40,max:1200}); +``` + +[Sheet Usage](/demo/sheet)의 채우기 핸들과 행열 리사이즈에서 확인할 수 있습니다. 드래그 미리보기는 문서를 변경하지 않고 확정 시 Editing Intent 하나를 실행합니다. 기존 Sheet의 수식 및 수열 채우기는 해당 Sheet 엔진에 유지되며, JSON/Markdown Hand의 `range.fill`은 원본 값 패턴을 반복 복제합니다. diff --git a/packages/json-document-affordance/package.json b/packages/json-document-affordance/package.json index 1be117c10..bceaacd1b 100644 --- a/packages/json-document-affordance/package.json +++ b/packages/json-document-affordance/package.json @@ -19,6 +19,8 @@ }, "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], "exports": { + "./grid-fill": {"types":"./dist/grid-fill.d.ts", "import":"./dist/grid-fill.js"}, + "./axis-resize": {"types": "./dist/axis-resize.d.ts", "import": "./dist/axis-resize.js"}, ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" diff --git a/packages/json-document-affordance/src/axis-resize.ts b/packages/json-document-affordance/src/axis-resize.ts new file mode 100644 index 000000000..5bce020a4 --- /dev/null +++ b/packages/json-document-affordance/src/axis-resize.ts @@ -0,0 +1,58 @@ +/** Axis resize contract promoted from dogfooding-sheet; product defaults remain with its consumers. */ +export interface ResizeBounds { + min: number + max?: number +} + +const STEP = 10 +const LARGE_STEP = 50 + +export function clampResizeValue(value: number, bounds: ResizeBounds): number { + const finite = Number.isFinite(value) ? value : bounds.min + const minClamped = Math.max(bounds.min, finite) + return bounds.max === undefined ? minClamped : Math.min(bounds.max, minClamped) +} + +export function storedResizeValue(value: number, bounds: ResizeBounds): number { + return Math.round(clampResizeValue(value, bounds)) +} + +export function resizeValueForKey( + current: number, + key: string, + shiftKey: boolean, + axis: 'x' | 'y', + bounds: ResizeBounds, +): number | null { + const step = shiftKey ? LARGE_STEP : STEP + if (key === 'PageUp') return clampResizeValue(current + LARGE_STEP, bounds) + if (key === 'PageDown') return clampResizeValue(current - LARGE_STEP, bounds) + if (key === 'Home') return bounds.min + if (key === 'End') return bounds.max ?? null + + const delta = + axis === 'x' + ? key === 'ArrowLeft' ? -step : key === 'ArrowRight' ? step : null + : key === 'ArrowUp' ? -step : key === 'ArrowDown' ? step : null + + return delta === null ? null : clampResizeValue(current + delta, bounds) +} + +export function collapseResizeValue( + current: number, + previous: number | null, + bounds: ResizeBounds, + defaultValue: number, +): { value: number; previous: number | null } { + const normalizedCurrent = clampResizeValue(current, bounds) + if (normalizedCurrent <= bounds.min) { + return { + value: clampResizeValue(previous ?? defaultValue, bounds), + previous: null, + } + } + return { + value: bounds.min, + previous: normalizedCurrent, + } +} diff --git a/packages/json-document-affordance/src/cell-editing.ts b/packages/json-document-affordance/src/cell-editing.ts index 1f6d915f4..2b897eb48 100644 --- a/packages/json-document-affordance/src/cell-editing.ts +++ b/packages/json-document-affordance/src/cell-editing.ts @@ -6,19 +6,22 @@ const activation = createWebKeyboardAdapter<"enter" | "previous" | "begin" | "ca keymap: {Enter: "enter", "Shift-Enter": "previous", F2: "begin", Escape: "cancel"}}); /** Cell edit meaning. Native text keys stay native while an edit is active. */ -export function cellEditingAffordance(stroke: WebKeyboardStroke, state: {readonly editing: boolean; readonly allSelected: boolean}): AffordancePreview { +export function cellEditingAffordance(stroke: WebKeyboardStroke, state: {readonly editing: boolean; readonly allSelected: boolean; readonly enter?: "edit" | "move"}): AffordancePreview { if (!stroke.metaKey && !stroke.ctrlKey && !stroke.altKey) { const focus = focusAffordance(stroke); if (focus.hand?.type === "tab") return focus; } const action = activation.resolve(stroke); if (state.editing) { + if (state.enter === "move" && stroke.key === "Enter" && stroke.ctrlKey && !stroke.metaKey && !stroke.altKey && !stroke.shiftKey) return {hand:{type:"rename",action:"commit",target:"selection"}}; if (action === "cancel") return renameAffordance({key: "Escape"}); if (action === "enter" || action === "previous") return {hand: {type: "rename", action: "commit", move: action === "previous" ? "up" : "down"}}; return {hand: null}; } const all = selectAllAffordance(stroke, state, {repeat: "preserve"}); if (all.hand) return all; + if (stroke.key === " " && !stroke.metaKey && !stroke.altKey && stroke.ctrlKey !== stroke.shiftKey) return {hand:{type:"select",operation:"replace",axis:stroke.ctrlKey ? "column" : "row"}}; + if (state.enter === "move" && (action === "enter" || action === "previous")) return {hand: {type: "move", direction: action === "previous" ? "up" : "down", operation: "replace"}}; if (action === "enter" || action === "begin") return renameAffordance({key: "F2"}); const text = webKeyboardText(stroke); return text === null ? {hand: null} : {hand: {type: "rename", action: "begin", initialText: text}}; diff --git a/packages/json-document-affordance/src/grid-fill.ts b/packages/json-document-affordance/src/grid-fill.ts new file mode 100644 index 000000000..c78561cef --- /dev/null +++ b/packages/json-document-affordance/src/grid-fill.ts @@ -0,0 +1,15 @@ +export interface GridFillBounds { + readonly rMin: number; readonly rMax: number; readonly cMin: number; readonly cMax: number; +} + +/** Extend a fill rectangle on the dominant axis; vertical wins ties, as in dogfooding-sheet. */ +export function extendGridFill(source: GridFillBounds, point: {readonly row: number;readonly column: number}, bounds: {readonly rowCount:number;readonly columnCount:number}): GridFillBounds { + const row = Math.max(0,Math.min(bounds.rowCount-1,point.row)); + const column = Math.max(0,Math.min(bounds.columnCount-1,point.column)); + const down = Math.max(0,row-source.rMax), up = Math.max(0,source.rMin-row); + const right = Math.max(0,column-source.cMax), left = Math.max(0,source.cMin-column); + if (Math.max(down,up,right,left) === 0) return source; + return Math.max(up,down) >= Math.max(left,right) + ? {...source,...(up > down ? {rMin:row} : {rMax:row})} + : {...source,...(left > right ? {cMin:column} : {cMax:column})}; +} diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index 801d040f3..0c7c67f9d 100644 --- a/packages/json-document-affordance/src/index.ts +++ b/packages/json-document-affordance/src/index.ts @@ -153,3 +153,8 @@ export { } from "./select.js"; export { cellEditingAffordance } from "./cell-editing.js"; + +export { clampResizeValue, storedResizeValue, resizeValueForKey, collapseResizeValue } from "./axis-resize.js"; +export type { ResizeBounds } from "./axis-resize.js"; +export { extendGridFill } from "./grid-fill.js"; +export type { GridFillBounds } from "./grid-fill.js"; diff --git a/packages/json-document-affordance/src/result.ts b/packages/json-document-affordance/src/result.ts index 2e34a5591..a755d39b8 100644 --- a/packages/json-document-affordance/src/result.ts +++ b/packages/json-document-affordance/src/result.ts @@ -15,6 +15,7 @@ export type AffordanceHand = readonly operation: SelectOperation; readonly rect?: AffordanceRect; readonly objectIds?: ReadonlyArray; + readonly axis?: "row" | "column"; } | { readonly type: "move"; readonly direction: AffordanceMoveDirection; readonly operation: "replace" | "extend" } | { readonly type: "boundary"; readonly edge: "start" | "end"; readonly operation: "replace" | "extend" } @@ -46,7 +47,7 @@ export type AffordanceHand = readonly edge?: "start" | "end"; readonly operation: "replace" | "extend"; } - | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel"; readonly initialText?: string; readonly move?: "up" | "down" } + | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel"; readonly initialText?: string; readonly move?: "up" | "down"; readonly target?: "selection" } | { readonly type: "activate" } | { readonly type: "press"; diff --git a/packages/json-document-affordance/tests/grid-interaction.test.ts b/packages/json-document-affordance/tests/grid-interaction.test.ts new file mode 100644 index 000000000..db62d588c --- /dev/null +++ b/packages/json-document-affordance/tests/grid-interaction.test.ts @@ -0,0 +1,26 @@ +import {expect,test} from 'vitest'; +import {extendGridFill,storedResizeValue,resizeValueForKey,collapseResizeValue,cellEditingAffordance} from '../src/index.js'; + +test('fill extends on one dominant axis, including upward and leftward extension',()=>{ + const source={rMin:2,rMax:3,cMin:2,cMax:3},bounds={rowCount:10,columnCount:8}; + expect(extendGridFill(source,{row:0,column:2},bounds)).toEqual({...source,rMin:0}); + expect(extendGridFill(source,{row:2,column:0},bounds)).toEqual({...source,cMin:0}); + expect(extendGridFill(source,{row:5,column:5},bounds)).toEqual({...source,rMax:5}); + expect(extendGridFill(source,{row:99,column:2},bounds)).toEqual({...source,rMax:9}); + expect(extendGridFill(source,{row:2,column:2},bounds)).toBe(source); +}); +test('axis limits and keyboard movement retain the sibling contract',()=>{ + const bounds={min:40,max:400}; + expect(storedResizeValue(99.6,bounds)).toBe(100); + expect(storedResizeValue(Infinity,bounds)).toBe(40); + expect(resizeValueForKey(100,'ArrowRight',true,'x',bounds)).toBe(150); + expect(resizeValueForKey(100,'ArrowDown',false,'x',bounds)).toBeNull(); + expect(collapseResizeValue(40,180,bounds,100)).toEqual({value:180,previous:null}); +}); +test('spreadsheet range fill and entry are explicit policy, document Enter keeps activation',()=>{ + const stroke={key:'Enter',metaKey:false,ctrlKey:false,shiftKey:false}; + expect(cellEditingAffordance(stroke,{editing:false,allSelected:false,enter:'move'}).hand).toMatchObject({type:'move',direction:'down'}); + expect(cellEditingAffordance(stroke,{editing:false,allSelected:false,enter:'edit'}).hand).toMatchObject({type:'rename',action:'begin'}); + expect(cellEditingAffordance({...stroke,ctrlKey:true},{editing:true,allSelected:false,enter:'move'}).hand).toMatchObject({action:'commit',target:'selection'}); + expect(cellEditingAffordance({...stroke,ctrlKey:true},{editing:true,allSelected:false,enter:'edit'}).hand).toBeNull(); +}); diff --git a/packages/json-document-editing/docs/api-reference.md b/packages/json-document-editing/docs/api-reference.md index 1f1e15957..7c473772c 100644 --- a/packages/json-document-editing/docs/api-reference.md +++ b/packages/json-document-editing/docs/api-reference.md @@ -1546,6 +1546,7 @@ interface SheetDocument extends Record { ```ts interface SheetEditor { + readonly capabilities: {readonly resize: boolean}; readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; readonly selectedCells: ReadonlyArray; @@ -1562,6 +1563,8 @@ interface SheetEditor { ```ts interface SheetEditorOptions extends EditingHistoryOptions { + /** False for formats such as GFM that cannot persist row heights or column widths. */ + readonly resize?: boolean; readonly structure?: SheetStructurePolicy; /** Restore selection when projecting a new source snapshot; missing cells are reconciled. */ readonly selection?: SheetSelection; @@ -1572,6 +1575,13 @@ interface SheetEditorOptions extends EditingHistoryOptions { ```ts type SheetIntent = | SheetStructureIntent + | { readonly type: "column.resize"; readonly columnId: string; readonly width: number } + | { readonly type: "row.resize"; readonly rowId: string; readonly height: number } + | { readonly type: "selection.range"; readonly range: SheetRange } + | { readonly type: "selection.row"; readonly rowId: string } + | { readonly type: "selection.column"; readonly columnId: string } + | { readonly type: "range.fill"; readonly source: SheetRange; readonly target: SheetRange } + | { readonly type: "selection.navigate"; readonly direction: SheetTraversalDirection; readonly topology?: SheetTopology } | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; @@ -1589,6 +1599,7 @@ type SheetIntent = readonly rowId: string; readonly columnId: string; readonly value: JSONValue; + readonly preserveSelection?: boolean; } | { readonly type: "clipboard.paste"; @@ -1596,6 +1607,11 @@ type SheetIntent = readonly topology?: SheetTopology; }; ``` +## `sheetNavigationTarget` + +```ts +sheetNavigationTarget(topology: GridTopology, selection: SheetSelection, direction: SheetTraversalDirection): { readonly point: GridPoint; readonly preserveRange: boolean; } | null +``` ## `SheetPoint` ```ts @@ -1625,7 +1641,7 @@ interface SheetRow extends Record { ```ts interface SheetSelection extends Record { readonly kind: "range"; - /** Primary range aliases retained for single-range consumers. */ + /** Anchor of the primary range; focus is the active cell and may move inside that range. */ readonly anchor: SheetPoint | null; readonly focus: SheetPoint | null; readonly ranges: ReadonlyArray; @@ -1664,6 +1680,11 @@ interface SheetStructurePolicy { ```ts type SheetTopology = GridTopology; ``` +## `SheetTraversalDirection` + +```ts +type SheetTraversalDirection = "previous" | "next" | "up" | "down"; +``` ## `TextChange` ```ts diff --git a/packages/json-document-editing/docs/sheet.md b/packages/json-document-editing/docs/sheet.md index d33cfa124..b7ff36f68 100644 --- a/packages/json-document-editing/docs/sheet.md +++ b/packages/json-document-editing/docs/sheet.md @@ -17,3 +17,13 @@ editor.dispatch(editor.structure.insertRow); `createMarkdownTableEditor(textEditor, position)`는 React 없이 Markdown과 Sheet를 연결합니다. Markdown 문법은 Markdown package를 사용하고, 원문 변경과 Undo/Redo는 TextEditor가 소유합니다. 표 구조는 `headerRows: 1`, `minimumColumns: 1` 정책을 사용합니다. 기존 `@interactive-os/json-document-markdown-react`의 동일 export는 deprecated 재export로 유지합니다. 새 소비자는 Editing package에서 import합니다. [Sheet Usage](/demo/sheet)의 Markdown 탭과 [Bear](/applications/bear)가 이 경로를 사용합니다. + +## 연속 입력과 구조 capability + +`selection.navigate`는 `previous`/`next`/`up`/`down`으로 활성 셀을 이동합니다. 사각 범위 안에서는 Tab 순서(가로)와 Enter 순서(세로)로 순환합니다. `selection.ranges`는 유지되고 `selection.focus`는 그 안에서 독립적으로 움직입니다. 기존 anchor는 primary 범위의 anchor이며 focus는 더 이상 항상 primary 끝점과 같지 않습니다. 단순 셀 선택은 기존처럼 둘이 일치합니다. + +`cell.commit`에 `preserveSelection:true`를 지정하면 연속 입력 중 선택을 유지합니다. `selection.row`, `selection.column`, `selection.range`는 헤더 및 드래그 선택의 명령입니다. 선택만 바꾸는 명령은 문서 History를 추가하지 않습니다. + +`range.fill`은 source 사각형의 값 패턴을 target 사각형에 반복하고 한 번의 History transaction으로 확정합니다. `column.resize`와 `row.resize`는 각각 `width`와 `height`를 문서에 저장합니다. `editor.capabilities.resize`가 false면 UI와 직접 Intent 모두 이 작업을 허용하지 않습니다. Markdown adapter는 GFM에 크기 저장 문법이 없으므로 resize를 지원하지 않습니다. + +`sheetNavigationTarget`은 위 연속 입력의 좌표 투영 API이며 순환 순서는 Selection의 `traverseGrid`에 위임합니다. diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index e59d1a1f0..10ee9baf2 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -234,3 +234,5 @@ export { diffText, type TextChange } from "./text-change.js"; export { sheetColumnLabel } from "./sheet-structure.js"; export type { SheetStructureIntent, SheetStructurePolicy, SheetStructureActions } from "./sheet-structure.js"; export { createMarkdownTableEditor } from "./markdown-table.js"; +export { sheetNavigationTarget } from "./sheet-navigation.js"; +export type { SheetTraversalDirection } from "./sheet-navigation.js"; diff --git a/packages/json-document-editing/src/markdown-table.ts b/packages/json-document-editing/src/markdown-table.ts index 76a5a6a3b..66d4d67a6 100644 --- a/packages/json-document-editing/src/markdown-table.ts +++ b/packages/json-document-editing/src/markdown-table.ts @@ -21,7 +21,7 @@ export function createMarkdownTableEditor(text: TextEditor, position: () => numb const table = readMarkdownTable(observed, position()); const width = table?.align.length || table?.rows[0]?.length || 0; const columns = Array.from({length: width}, (_, i) => ({id: `c${i}`, label: sheetColumnLabel(i)})); - sheet = createSheetEditor({columns, rows: (table?.rows ?? []).map((row, i) => ({id: `r${i}`, cells: Object.fromEntries(columns.map((column, j) => [column.id, row[j] ?? ""]))}))}, {structure: {headerRows: 1, minimumColumns: 1}, ...(selected ? {selection: selected} : {})}); + sheet = createSheetEditor({columns, rows: (table?.rows ?? []).map((row, i) => ({id: `r${i}`, cells: Object.fromEntries(columns.map((column, j) => [column.id, row[j] ?? ""]))}))}, {resize:false, structure: {headerRows: 1, minimumColumns: 1}, ...(selected ? {selection: selected} : {})}); }; const snapshot = () => {read(); return {...sheet.snapshot, revision, canUndo: text.snapshot.canUndo, canRedo: text.snapshot.canRedo};}; const publish = () => {revision++; const next = snapshot(); listeners.forEach(listener => listener(next));}; @@ -31,7 +31,7 @@ export function createMarkdownTableEditor(text: TextEditor, position: () => numb if (!table) return {ok: false, code: "table.unavailable"}; const result = sheet.dispatch(intent); if (!result.ok) return result; - if (intent.type === "selection.set" || intent.type === "selection.select-all") {publish(); return {ok: true, snapshot: snapshot()};} + if (intent.type === "selection.set" || intent.type === "selection.select-all" || intent.type === "selection.navigate" || intent.type === "selection.range" || intent.type === "selection.row" || intent.type === "selection.column") {publish(); return {ok: true, snapshot: snapshot()};} return commit(table); }; const commit = (table: MarkdownTable): EditingResult => { @@ -54,6 +54,7 @@ export function createMarkdownTableEditor(text: TextEditor, position: () => numb publish(); return {ok: true, snapshot: snapshot()}; }; return { + get capabilities() {read(); return sheet.capabilities;}, get structure() {read(); return sheet.structure;}, get snapshot() {return snapshot();}, get selectedCells() {read(); return sheet.selectedCells;}, diff --git a/packages/json-document-editing/src/sheet-navigation.ts b/packages/json-document-editing/src/sheet-navigation.ts new file mode 100644 index 000000000..ac1d39832 --- /dev/null +++ b/packages/json-document-editing/src/sheet-navigation.ts @@ -0,0 +1,31 @@ +import { traverseGrid } from "@interactive-os/json-document-selection"; +import { gridPointIndex, gridRangeBounds, type GridPoint, type GridTopology } from "./topology.js"; +import type { SheetSelection } from "./sheet.js"; + +export type SheetTraversalDirection = "previous" | "next" | "up" | "down"; + +/** Sequential entry preserves a rectangular selection while moving its active cell. */ +export function sheetNavigationTarget(topology: GridTopology, selection: SheetSelection, direction: SheetTraversalDirection): {readonly point: GridPoint; readonly preserveRange: boolean} | null { + const focus = selection.focus; + const index = focus && gridPointIndex(topology, focus); + if (!index) return null; + const primary = selection.primaryIndex === null ? undefined : selection.ranges[selection.primaryIndex]; + const bounds = primary && gridRangeBounds(topology, primary); + const inRange = bounds && index.rowIndex >= bounds.rowStart && index.rowIndex <= bounds.rowEnd + && index.columnIndex >= bounds.columnStart && index.columnIndex <= bounds.columnEnd; + if (bounds && inRange && (bounds.rowEnd > bounds.rowStart || bounds.columnEnd > bounds.columnStart)) { + const next = traverseGrid({rowIndex: index.rowIndex - bounds.rowStart, columnIndex: index.columnIndex - bounds.columnStart}, { + rowCount: bounds.rowEnd - bounds.rowStart + 1, columnCount: bounds.columnEnd - bounds.columnStart + 1, + order: direction === "up" || direction === "down" ? "column-major" : "row-major", + reverse: direction === "previous" || direction === "up", wrap: true, + }); + return next ? {point: {rowId: topology.rowIds[next.rowIndex + bounds.rowStart]!, columnId: topology.columnIds[next.columnIndex + bounds.columnStart]!}, preserveRange: true} : null; + } + if (direction === "up" || direction === "down") { + const rowId = topology.rowIds[index.rowIndex + (direction === "up" ? -1 : 1)]; + return rowId === undefined ? null : {point: {rowId, columnId: focus!.columnId}, preserveRange: false}; + } + const next = traverseGrid(index, {rowCount: topology.rowIds.length, columnCount: topology.columnIds.length, + order: "row-major", reverse: direction === "previous"}); + return next ? {point: {rowId: topology.rowIds[next.rowIndex]!, columnId: topology.columnIds[next.columnIndex]!}, preserveRange: false} : null; +} diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index 872772625..f0fe526c5 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -26,6 +26,7 @@ import { type SelectionRange, } from "./range-selection.js"; import { jsonCellText } from "./cell-text.js"; +import { sheetNavigationTarget, type SheetTraversalDirection } from "./sheet-navigation.js"; export interface SheetColumn extends Record { readonly id: string; @@ -54,7 +55,7 @@ export interface SheetRange extends Record { export interface SheetSelection extends Record { readonly kind: "range"; - /** Primary range aliases retained for single-range consumers. */ + /** Anchor of the primary range; focus is the active cell and may move inside that range. */ readonly anchor: SheetPoint | null; readonly focus: SheetPoint | null; readonly ranges: ReadonlyArray; @@ -86,6 +87,13 @@ export const sheetClipboardFormat = { export type SheetIntent = | SheetStructureIntent + | { readonly type: "column.resize"; readonly columnId: string; readonly width: number } + | { readonly type: "row.resize"; readonly rowId: string; readonly height: number } + | { readonly type: "selection.range"; readonly range: SheetRange } + | { readonly type: "selection.row"; readonly rowId: string } + | { readonly type: "selection.column"; readonly columnId: string } + | { readonly type: "range.fill"; readonly source: SheetRange; readonly target: SheetRange } + | { readonly type: "selection.navigate"; readonly direction: SheetTraversalDirection; readonly topology?: SheetTopology } | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; @@ -103,6 +111,7 @@ export type SheetIntent = readonly rowId: string; readonly columnId: string; readonly value: JSONValue; + readonly preserveSelection?: boolean; } | { readonly type: "clipboard.paste"; @@ -111,6 +120,7 @@ export type SheetIntent = }; export interface SheetEditor { + readonly capabilities: {readonly resize: boolean}; readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; readonly selectedCells: ReadonlyArray; @@ -124,6 +134,8 @@ export interface SheetEditor { } export interface SheetEditorOptions extends EditingHistoryOptions { + /** False for formats such as GFM that cannot persist row heights or column widths. */ + readonly resize?: boolean; readonly structure?: SheetStructurePolicy; /** Restore selection when projecting a new source snapshot; missing cells are reconciled. */ readonly selection?: SheetSelection; @@ -136,7 +148,7 @@ export function createSheetEditor(source: EditingDocumentSource, const firstRow = initial.rows[0]; const firstColumn = initial.columns[0]; const initialSelection = options.selection - ? withPrimaryAliases(reconcileRangeSelection(options.selection, point => initial.rows.some(row => row.id === point.rowId) && initial.columns.some(column => column.id === point.columnId) ? point : null)) + ? withPrimaryAliases(reconcileRangeSelection(options.selection, point => initial.rows.some(row => row.id === point.rowId) && initial.columns.some(column => column.id === point.columnId) ? point : null), options.selection.focus && initial.rows.some(row => row.id === options.selection?.focus?.rowId) && initial.columns.some(column => column.id === options.selection?.focus?.columnId) ? options.selection.focus : null) : firstRow && firstColumn ? collapsed(firstRow.id, firstColumn.id) : emptySelection(); const session = createEditingSession({ ...options, @@ -146,7 +158,8 @@ export function createSheetEditor(source: EditingDocumentSource, const sheet = value as SheetDocument; return sheet.rows.some((row) => row.id === point.rowId) && sheet.columns.some((column) => column.id === point.columnId) ? point : null; - })), + }), selection.focus && (value as SheetDocument).rows.some(row => row.id === selection.focus?.rowId) + && (value as SheetDocument).columns.some(column => column.id === selection.focus?.columnId) ? selection.focus : null), }); let indexedDocument: SheetDocument | undefined = initial; let indexedSheet: SheetIndex | undefined = createSheetIndex(initial); @@ -207,6 +220,48 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + if (intent.type === "selection.row" || intent.type === "selection.column") { + const current=value(), firstRow=current.rows[0],lastRow=current.rows.at(-1),firstColumn=current.columns[0],lastColumn=current.columns.at(-1); + if(!firstRow || !lastRow || !firstColumn || !lastColumn) return failure("selection.empty"); + return dispatch({type:"selection.range",range:intent.type === "selection.row" + ? {anchor:{rowId:intent.rowId,columnId:firstColumn.id},focus:{rowId:intent.rowId,columnId:lastColumn.id}} + : {anchor:{rowId:firstRow.id,columnId:intent.columnId},focus:{rowId:lastRow.id,columnId:intent.columnId}}}); + } + if (intent.type === "range.fill") { + const current = value(); + const topology = resolveTopology(current, undefined, index()); + const source = gridRangeBounds(topology,intent.source), target = gridRangeBounds(topology,intent.target); + if (!source || !target || target.rowStart > source.rowStart || target.rowEnd < source.rowEnd + || target.columnStart > source.columnStart || target.columnEnd < source.columnEnd) return failure("sheet.invalid-fill"); + const operations: JSONPatchOperation[] = []; + const modulo = (value:number,count:number) => (value % count + count) % count; + for (let r=target.rowStart;r<=target.rowEnd;r++) for(let c=target.columnStart;c<=target.columnEnd;c++) { + if (r>=source.rowStart && r<=source.rowEnd && c>=source.columnStart && c<=source.columnEnd) continue; + const row=source.rowStart+modulo(r-source.rowStart,source.rowEnd-source.rowStart+1); + const column=source.columnStart+modulo(c-source.columnStart,source.columnEnd-source.columnStart+1); + operations.push({op:"replace",path:buildPointer(["rows",r,"cells",current.columns[c]!.id]),value:current.rows[row]!.cells[current.columns[column]!.id]!}); + } + return session.apply({operations,selectionAfter:withPrimaryAliases(replaceRangeSelection(session.snapshot.selection,intent.target,sameSheetPoint)),origin:"range.fill"}); + } + if (intent.type === "selection.range") { + if (!resolvePoint(value(), intent.range.anchor.rowId, intent.range.anchor.columnId, index()) + || !resolvePoint(value(), intent.range.focus.rowId, intent.range.focus.columnId, index())) return failure("selection.cell-not-found"); + return success(session.select(withPrimaryAliases(replaceRangeSelection(session.snapshot.selection, intent.range, sameSheetPoint)))); + } + if (intent.type === "column.resize" || intent.type === "row.resize") { + if (options.resize === false) return failure("sheet.resize-unavailable"); + const column = intent.type === "column.resize"; + const size = column ? intent.width : intent.height; + const position = column ? value().columns.findIndex(c => c.id === intent.columnId) : value().rows.findIndex(r => r.id === intent.rowId); + if (position < 0 || !Number.isFinite(size) || size <= 0) return failure("sheet.invalid-size"); + return session.apply({operations: [{op:"add",path:buildPointer([column ? "columns" : "rows", position, column ? "width" : "height"]),value:size}], + selectionAfter:session.snapshot.selection,origin:intent.type}); + } + if (intent.type === "selection.navigate") { + const next = sheetNavigationTarget(resolveTopology(value(), intent.topology, index()), session.snapshot.selection, intent.direction); + if (!next) return failure("selection.boundary"); + return success(session.select(next.preserveRange ? withPrimaryAliases(session.snapshot.selection, {...next.point}) : collapsed(next.point.rowId, next.point.columnId))); + } if (intent.type === "row.insert" || intent.type === "row.delete" || intent.type === "column.insert" || intent.type === "column.delete") { const current = value(); const violation = sheetStructureViolation(current, intent, options.structure ?? {}); @@ -275,7 +330,7 @@ export function createSheetEditor(source: EditingDocumentSource, path: buildPointer(["rows", resolved.rowIndex, "cells", intent.columnId]), value: intent.value, }], - selectionAfter: collapsed(intent.rowId, intent.columnId), + selectionAfter: intent.preserveSelection ? session.snapshot.selection : collapsed(intent.rowId, intent.columnId), origin: intent.type, historyGroup: `cell:${intent.rowId}:${intent.columnId}`, }); @@ -326,6 +381,7 @@ export function createSheetEditor(source: EditingDocumentSource, } return { + get capabilities() {return {resize: options.resize !== false};}, get structure() { return sheetStructureActions(value(), session.snapshot.selection, options.structure ?? {}); }, get snapshot() { return session.snapshot; }, get selectedCells() { return selectedCells(); }, @@ -479,12 +535,13 @@ function emptySelection(): SheetSelection { function withPrimaryAliases( selection: RangeSelectionState, + active?: SheetPoint | null, ): SheetSelection { const primary = primaryRange(selection); return { kind: "range", anchor: primary?.anchor ?? null, - focus: primary?.focus ?? null, + focus: active ?? primary?.focus ?? null, ranges: selection.ranges.map((range) => ({ anchor: { ...range.anchor }, focus: { ...range.focus }, diff --git a/packages/json-document-editing/tests/sheet-navigation.test.ts b/packages/json-document-editing/tests/sheet-navigation.test.ts new file mode 100644 index 000000000..182accf1d --- /dev/null +++ b/packages/json-document-editing/tests/sheet-navigation.test.ts @@ -0,0 +1,48 @@ +import {createJSONDocument} from "@interactive-os/json-document"; +import {expect, test} from "vitest"; +import {createSheetEditor, createMarkdownTableEditor, createTextEditor, type SheetDocument} from "../src/index.js"; + +test("range entry retains range, moves active cell independently and restores it through undo", () => { + const editor = createSheetEditor({columns: [{id:"a",label:"A"},{id:"b",label:"B"}],rows:[{id:"1",cells:{a:"one",b:"two"}},{id:"2",cells:{a:"three",b:"four"}}]}); + editor.dispatch({type:"selection.set",rowId:"2",columnId:"b",mode:"extend"}); + const ranges = editor.snapshot.selection.ranges; + editor.dispatch({type:"selection.navigate",direction:"next"}); + expect(editor.snapshot.selection.focus).toEqual({rowId:"1",columnId:"a"}); + expect(editor.snapshot.selection.ranges).toEqual(ranges); + editor.dispatch({type:"cell.commit",rowId:"1",columnId:"a",value:"changed",preserveSelection:true}); + editor.dispatch({type:"selection.navigate",direction:"down"}); + expect(editor.snapshot.selection.focus).toEqual({rowId:"2",columnId:"a"}); + expect(editor.selectedCells).toHaveLength(4); + editor.undo(); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("one"); + expect(editor.snapshot.selection.ranges).toEqual(ranges); +}); + +test("Markdown traversal is selection-only; a commit retains its range and source history", () => { + const text = createTextEditor(createJSONDocument("before\n\n| A | B |\n| --- | --- |\n| one | two |\n\nafter")); + const editor = createMarkdownTableEditor(text, () => 8); + editor.dispatch({type:"selection.set",rowId:"r1",columnId:"c1",mode:"extend"}); + const original = text.text; + editor.dispatch({type:"selection.navigate",direction:"next"}); + expect(text.text).toBe(original); + expect(text.snapshot.canUndo).toBe(false); + editor.dispatch({type:"cell.commit",rowId:"r0",columnId:"c0",value:"changed",preserveSelection:true}); + expect(editor.selectedCells).toHaveLength(4); + expect(editor.snapshot.selection.focus).toEqual({rowId:"r0",columnId:"c0"}); + editor.undo(); expect(text.text).toBe(original); +}); + +test('fill and size changes are atomic and format capabilities also reject direct intents',()=>{ + const editor=createSheetEditor({columns:[{id:'a',label:'A'},{id:'b',label:'B'}],rows:[{id:'1',cells:{a:'one',b:'two'}},{id:'2',cells:{a:'old',b:'old'}}]}); + const before=editor.snapshot.value; + expect(editor.dispatch({type:'range.fill',source:{anchor:{rowId:'1',columnId:'a'},focus:{rowId:'1',columnId:'b'}},target:{anchor:{rowId:'1',columnId:'a'},focus:{rowId:'2',columnId:'b'}}}).ok).toBe(true); + expect((editor.snapshot.value as SheetDocument).rows[1]!.cells).toEqual({a:'one',b:'two'}); + editor.undo();expect(editor.snapshot.value).toEqual(before); + editor.dispatch({type:'column.resize',columnId:'a',width:143});expect((editor.snapshot.value as SheetDocument).columns[0]!.width).toBe(143); + editor.undo();expect(editor.snapshot.value).toEqual(before); + const text=createTextEditor(createJSONDocument('| A |\n| --- |\n| text |')); + const markdown=createMarkdownTableEditor(text,()=>0); + expect(markdown.capabilities.resize).toBe(false); + expect(markdown.dispatch({type:'column.resize',columnId:'c0',width:143})).toEqual({ok:false,code:'sheet.resize-unavailable'}); + expect(text.snapshot.canUndo).toBe(false); +}); diff --git a/packages/json-document-markdown-react/docs/api-reference.md b/packages/json-document-markdown-react/docs/api-reference.md index 04940286e..8b6081fa3 100644 --- a/packages/json-document-markdown-react/docs/api-reference.md +++ b/packages/json-document-markdown-react/docs/api-reference.md @@ -11,6 +11,11 @@ Markdown 렌더링·편집 표면과 React 수명 연결의 public entrypoint입 ```ts createMarkdownTableEditor(text: TextEditor, position: () => number): SheetEditor ``` +## `MarkdownCellEditor` + +```ts +MarkdownCellEditor({ label, value, onValueChange, style, onKeyDown, onBlur }: SheetCellEditorProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` ## `MarkdownComponents` ```ts diff --git a/packages/json-document-markdown-react/docs/editing.md b/packages/json-document-markdown-react/docs/editing.md index 829435c2a..4d36b55b8 100644 --- a/packages/json-document-markdown-react/docs/editing.md +++ b/packages/json-document-markdown-react/docs/editing.md @@ -38,3 +38,9 @@ Markdown Web과 공용 contenteditable 투영을 사용합니다. `MarkdownEditingSurface`는 최상위 GFM 표에 `SheetHand`를 연결합니다. `@interactive-os/json-document-editing`의 `createMarkdownTableEditor(editor, position)`는 Sheet Intent를 Markdown table 원문 변경으로 변환하고 문서 전체 Undo/Redo를 재사용합니다. 셀 UI와 입력 상태는 Hand, 문법 처리는 Markdown, 원문과 History는 TextEditor가 소유합니다. [Sheet API](/docs/api/sheet)와 [Bear](/applications/bear)에서 확인할 수 있습니다. 이 패키지의 기존 `createMarkdownTableEditor` export는 호환용 deprecated 재export입니다. 편집 어댑터 구현과 API 문서는 [Editing owner](/docs/api/editing)에 있습니다. + +## 표 셀 초안 + +`MarkdownCellEditor`는 `SheetCellEditorProps`의 초안을 편집하는 포맷 소유 컴포넌트입니다. `SheetHand.renderEditor`로 연결하며, 확정 전에는 표 원문을 변경하지 않습니다. 기존 Markdown DOM binding에 `revealSyntax:false`를 사용하므로 편집 시에도 strong/emphasis 등 표시를 유지합니다. 초안 입력 수명과 한글 조합은 contenteditable 정본 binding이 담당하고 최종 확정·취소·표 이동은 Hand가 담당합니다. + +[Sheet Usage](/demo/sheet)의 Markdown 탭과 [Bear](/applications/bear)가 같은 연결을 사용합니다. Markdown cell editor를 사용할 때 기존 `markdown-editor.css`도 함께 로드해야 합니다. diff --git a/packages/json-document-markdown-react/package.json b/packages/json-document-markdown-react/package.json index c38dedb4e..491004cfb 100644 --- a/packages/json-document-markdown-react/package.json +++ b/packages/json-document-markdown-react/package.json @@ -42,6 +42,7 @@ "remark-gfm": "^4.0.1" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "react": "^18.0.0 || ^19.0.0", "@interactive-os/json-document-editing": "^0.1.0-rc.0", "@interactive-os/json-document-markdown-web": "^0.1.0-rc.0", @@ -50,6 +51,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document": "*", "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^5.2.0", "@types/react": "^19.2.14", diff --git a/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx b/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx new file mode 100644 index 000000000..390ba3665 --- /dev/null +++ b/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx @@ -0,0 +1,25 @@ +import {useEffect, useRef, useState} from "react"; +import {createJSONDocument} from "@interactive-os/json-document"; +import {createTextEditor} from "@interactive-os/json-document-editing"; +import {createMarkdownEditingBinding} from "@interactive-os/json-document-markdown-web"; +import type {SheetCellEditorProps} from "@interactive-os/json-document-sheet"; + +/** A draft-only Markdown editor. The table's editor owns the persisted source and its history. */ +export function MarkdownCellEditor({label,value,onValueChange,style,onKeyDown,onBlur}: SheetCellEditorProps) { + const root = useRef(null); + const change = useRef(onValueChange);change.current = onValueChange; + const [editor] = useState(() => createTextEditor(createJSONDocument(value))); + useEffect(() => { + if (!root.current) return; + const element = root.current; + let observed = editor.text; + const unsubscribe = editor.subscribe(() => {if (editor.text !== observed) {observed=editor.text;change.current(observed);}}); + const binding = createMarkdownEditingBinding({editor,root:element,revealSyntax:false}); + const unbind = binding.bind(); + element.focus();editor.select({anchor:0,focus:editor.text.length}); + return () => {unsubscribe();unbind();}; + }, [editor]); + useEffect(() => {if (editor.text !== value) editor.replace(value,editor.snapshot.selection);}, [editor,value]); + return
event.stopPropagation()} onKeyDown={onKeyDown} onBlur={onBlur} style={style} />; +} diff --git a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx index 860f88be4..27bb3f63e 100644 --- a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx +++ b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, type HTMLAttributes } from "react"; import { markdownTableBoundary, readMarkdownTable } from "@interactive-os/json-document-markdown"; import { MarkdownRenderer } from "./MarkdownRenderer.js"; +import { MarkdownCellEditor } from "./MarkdownCellEditor.js"; import { createRoot } from "react-dom/client"; import { SheetHand } from "@interactive-os/json-document-sheet"; import { createMarkdownTableEditor } from "@interactive-os/json-document-editing"; @@ -21,12 +22,12 @@ export function MarkdownEditingSurface({ editor, style, ...props }: MarkdownEdit const binding = createMarkdownEditingBinding({editor, root, mountTable(element, position) { const reactRoot = createRoot(element); const table = createMarkdownTableEditor(editor, position); - reactRoot.render( { + reactRoot.render( { const current = readMarkdownTable(editor.text, position()); if (!current) return; const offset = markdownTableBoundary(editor.text, current, edge); root.focus(); editor.select({anchor: offset, focus: offset}); - }} renderCell={value => {children}}} />} />); + }} renderCell={value => {children}}} />} renderEditor={props => } />); const dispose = () => {disposals.delete(dispose); queueMicrotask(() => reactRoot.unmount());}; disposals.add(dispose); return dispose; diff --git a/packages/json-document-markdown-react/src/index.ts b/packages/json-document-markdown-react/src/index.ts index e85cd0b09..8b0ba682c 100644 --- a/packages/json-document-markdown-react/src/index.ts +++ b/packages/json-document-markdown-react/src/index.ts @@ -1,5 +1,6 @@ export { MarkdownRenderer, type MarkdownComponents, type MarkdownRendererProps } from "./MarkdownRenderer.js"; export { projectStreamingMarkdown, type StreamingMarkdownProjection } from "./streaming-markdown.js"; export { MarkdownEditingSurface, type MarkdownEditingSurfaceProps } from "./MarkdownEditingSurface.js"; +export { MarkdownCellEditor } from "./MarkdownCellEditor.js"; /** @deprecated Import the source editor from @interactive-os/json-document-editing. */ export { createMarkdownTableEditor } from "@interactive-os/json-document-editing"; diff --git a/packages/json-document-markdown-react/tsconfig.json b/packages/json-document-markdown-react/tsconfig.json index 3516acae9..d2c14e307 100644 --- a/packages/json-document-markdown-react/tsconfig.json +++ b/packages/json-document-markdown-react/tsconfig.json @@ -10,6 +10,7 @@ "src/**/*.tsx" ], "references": [ + { "path": "../json-document" }, { "path": "../json-document-editing" }, diff --git a/packages/json-document-markdown-web/docs/api-reference.md b/packages/json-document-markdown-web/docs/api-reference.md index fe679435c..ea41bf6e2 100644 --- a/packages/json-document-markdown-web/docs/api-reference.md +++ b/packages/json-document-markdown-web/docs/api-reference.md @@ -14,12 +14,14 @@ createMarkdownDOMAdapter(options?: MarkdownDOMOptions): TextDOMAdapter ## `createMarkdownEditingBinding` ```ts -createMarkdownEditingBinding({ editor, root, mountTable }: MarkdownEditingBindingOptions): ContentEditableBinding +createMarkdownEditingBinding({ editor, root, mountTable, revealSyntax }: MarkdownEditingBindingOptions): ContentEditableBinding ``` ## `MarkdownDOMOptions` ```ts interface MarkdownDOMOptions { + /** Keep inline syntax concealed during editing, e.g. an embedded document-table cell. */ + readonly revealSyntax?: boolean; /** Enables task controls using the existing source editor and its history. */ readonly editor?: TextEditor; /** Mount a table Hand in a source-excluded island. Return its disposal callback. */ @@ -33,5 +35,6 @@ interface MarkdownEditingBindingOptions { readonly editor: TextEditor; readonly root: HTMLElement; readonly mountTable?: MarkdownDOMOptions["mountTable"]; + readonly revealSyntax?: boolean; } ``` diff --git a/packages/json-document-markdown-web/src/editing-binding.ts b/packages/json-document-markdown-web/src/editing-binding.ts index 870be7c99..bb899b460 100644 --- a/packages/json-document-markdown-web/src/editing-binding.ts +++ b/packages/json-document-markdown-web/src/editing-binding.ts @@ -7,13 +7,14 @@ export interface MarkdownEditingBindingOptions { readonly editor: TextEditor; readonly root: HTMLElement; readonly mountTable?: MarkdownDOMOptions["mountTable"]; + readonly revealSyntax?: boolean; } /** Connect Markdown DOM, syntax-owned Enter, and the editor's existing history. */ -export function createMarkdownEditingBinding({editor, root, mountTable}: MarkdownEditingBindingOptions): ContentEditableBinding { +export function createMarkdownEditingBinding({editor, root, mountTable, revealSyntax}: MarkdownEditingBindingOptions): ContentEditableBinding { return createContentEditableBinding({ document: editor.document, pointer: editor.pointer, editor, root, - dom: createMarkdownDOMAdapter({editor, ...(mountTable ? {mountTable} : {})}), + dom: createMarkdownDOMAdapter({editor, ...(mountTable ? {mountTable} : {}), ...(revealSyntax === undefined ? {} : {revealSyntax})}), indent(editor, direction) { const next = indentMarkdownList(editor.text, editor.snapshot.selection, direction); return next ? editor.replace(next.value, next.selection) : null; diff --git a/packages/json-document-markdown-web/src/markdown-dom.ts b/packages/json-document-markdown-web/src/markdown-dom.ts index 429aa5320..3d4242ffd 100644 --- a/packages/json-document-markdown-web/src/markdown-dom.ts +++ b/packages/json-document-markdown-web/src/markdown-dom.ts @@ -25,6 +25,8 @@ interface Surface { } export interface MarkdownDOMOptions { + /** Keep inline syntax concealed during editing, e.g. an embedded document-table cell. */ + readonly revealSyntax?: boolean; /** Enables task controls using the existing source editor and its history. */ readonly editor?: TextEditor; /** Mount a table Hand in a source-excluded island. Return its disposal callback. */ @@ -40,7 +42,7 @@ export function createMarkdownDOMAdapter(options: MarkdownDOMOptions = {}): Text if (surface.selection === selection || (selection && surface.selection?.anchor === selection.anchor && surface.selection.focus === selection.focus)) return; const visit = ({ element, run, children }: RenderedRun): void => { if (run.owner) { - const active = selection !== null && Math.max(selection.anchor, selection.focus) >= run.owner.from && Math.min(selection.anchor, selection.focus) <= run.owner.to; + const active = options.revealSyntax !== false && selection !== null && Math.max(selection.anchor, selection.focus) >= run.owner.from && Math.min(selection.anchor, selection.focus) <= run.owner.to; if (run.conceal) element.hidden = run.conceal === "always" || !active; else if (run.kind === "imagePreview") element.hidden = active; else if (element.getAttribute("data-markdown-active") !== String(active)) element.setAttribute("data-markdown-active", String(active)); diff --git a/packages/json-document-markdown-web/tests/markdown-dom.test.ts b/packages/json-document-markdown-web/tests/markdown-dom.test.ts index 13c3111a8..4b040d4a6 100644 --- a/packages/json-document-markdown-web/tests/markdown-dom.test.ts +++ b/packages/json-document-markdown-web/tests/markdown-dom.test.ts @@ -173,3 +173,13 @@ test("hidden quote prefix deletes as one source unit and leaves extra spaces", ( expect(dom.resolveDeletionSelection!(root, {anchor:2, focus:2}, "backward")).toEqual({anchor:0, focus:2}); expect(dom.observe(root).value).toBe(source); }); + +test('embedded cell editing conceals syntax while retaining source coordinates',()=>{ + const root=document.createElement('div');document.body.append(root); + const source='**한글** and *text*';const dom=createMarkdownDOMAdapter({revealSyntax:false}); + dom.render(root,source,{anchor:2,focus:4}); + expect(root.querySelector('strong')?.textContent).toContain('한글'); + expect([...root.querySelectorAll('[data-markdown-delimiter]')].every(el=>el.hidden)).toBe(true); + expect(dom.restoreSelection(root,{anchor:2,focus:4})).toBe(true); + expect(dom.observe(root)).toEqual({value:source,selection:{anchor:2,focus:4}}); +}); diff --git a/packages/json-document-selection/docs/api-reference.md b/packages/json-document-selection/docs/api-reference.md index c8f136d37..6b2c4e7f6 100644 --- a/packages/json-document-selection/docs/api-reference.md +++ b/packages/json-document-selection/docs/api-reference.md @@ -48,6 +48,26 @@ emptyMaterializedRangeSelection(): MaterializedRangeSelection ```ts emptyRangeSelection(): RangeSelection ``` +## `GridTraversalIndex` + +```ts +interface GridTraversalIndex { + readonly rowIndex: number; + readonly columnIndex: number; +} +``` +## `GridTraversalOptions` + +```ts +interface GridTraversalOptions { + readonly rowCount: number; + readonly columnCount: number; + readonly order: "row-major" | "column-major"; + readonly reverse?: boolean; + /** Cycle inside an explicitly selected rectangle. Outside a selection, stop at the boundary. */ + readonly wrap?: boolean; +} +``` ## `idlePointerInteraction` ```ts @@ -484,3 +504,8 @@ interface SpatialIndex { hitRegion(region: Region, mode: "intersects" | "contains"): readonly Key[]; } ``` +## `traverseGrid` + +```ts +traverseGrid(index: GridTraversalIndex, options: GridTraversalOptions): GridTraversalIndex | null +``` diff --git a/packages/json-document-selection/docs/grid-traversal.md b/packages/json-document-selection/docs/grid-traversal.md new file mode 100644 index 000000000..3a3322468 --- /dev/null +++ b/packages/json-document-selection/docs/grid-traversal.md @@ -0,0 +1,11 @@ +# Grid traversal + +`traverseGrid(index, options)`는 보이는 두 축의 순번을 순회합니다. row ID, A1 이름, DOM, 문서 변경은 소비자가 변환합니다. `row-major`는 Tab 입력 순서, `column-major`는 Enter 입력 순서이며 `reverse`로 역방향, `wrap`으로 선택 사각형 안의 순환을 지정합니다. 범위 밖 이동과 유효하지 않은 좌표는 `null`입니다. 전체 셀 배열을 만들지 않으므로 큰 희소 시트에도 사용할 수 있습니다. + +```ts +import { traverseGrid } from '@interactive-os/json-document-selection'; +traverseGrid({rowIndex:1,columnIndex:1}, {rowCount:2,columnCount:2,order:'row-major',wrap:true}); +// {rowIndex:0,columnIndex:0} +``` + +[Sheet Usage](/demo/sheet)의 `selection.navigate`와 Web의 순차 이동이 이 API를 사용합니다. 형제 `dogfooding-sheet`의 `rangeTabTarget`과 `rangeEnterTarget`도 같은 함수를 사용하며 A1 및 숨김 축의 변환을 유지합니다. 포인터 기반 선택 시작·미리보기·종료는 두 소비자 모두 기존 `reducePressInteraction`을 사용합니다. diff --git a/packages/json-document-selection/package.json b/packages/json-document-selection/package.json index 00075f60b..0f4fd756d 100644 --- a/packages/json-document-selection/package.json +++ b/packages/json-document-selection/package.json @@ -17,7 +17,7 @@ "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/json-document-selection/src/index.ts b/packages/json-document-selection/src/index.ts index e5cbe610c..0be8a2d0a 100644 --- a/packages/json-document-selection/src/index.ts +++ b/packages/json-document-selection/src/index.ts @@ -1,4 +1,6 @@ export { selectionResult } from "./core/family.js"; +export { traverseGrid } from "./interaction/grid-traversal.js"; +export type { GridTraversalIndex, GridTraversalOptions } from "./interaction/grid-traversal.js"; export { createKeySelectionFamily, emptyKeySelection, diff --git a/packages/json-document-selection/src/interaction/grid-traversal.ts b/packages/json-document-selection/src/interaction/grid-traversal.ts new file mode 100644 index 000000000..7a575b5ad --- /dev/null +++ b/packages/json-document-selection/src/interaction/grid-traversal.ts @@ -0,0 +1,31 @@ +/** Ordinal coordinates in the visible axes, independent of cell IDs and DOM. */ +export interface GridTraversalIndex { + readonly rowIndex: number; + readonly columnIndex: number; +} + +export interface GridTraversalOptions { + readonly rowCount: number; + readonly columnCount: number; + readonly order: "row-major" | "column-major"; + readonly reverse?: boolean; + /** Cycle inside an explicitly selected rectangle. Outside a selection, stop at the boundary. */ + readonly wrap?: boolean; +} + +/** Traverse visible rectangular axes without materializing their cells (including large sparse sheets). */ +export function traverseGrid(index: GridTraversalIndex, options: GridTraversalOptions): GridTraversalIndex | null { + const {rowCount, columnCount, order} = options; + if (![rowCount, columnCount, index.rowIndex, index.columnIndex].every(Number.isSafeInteger) + || rowCount <= 0 || columnCount <= 0 || !Number.isSafeInteger(rowCount * columnCount) + || index.rowIndex < 0 || index.rowIndex >= rowCount || index.columnIndex < 0 || index.columnIndex >= columnCount) return null; + const width = order === "row-major" ? columnCount : rowCount; + const offset = order === "row-major" ? index.rowIndex * width + index.columnIndex : index.columnIndex * width + index.rowIndex; + const count = rowCount * columnCount; + let next = offset + (options.reverse ? -1 : 1); + if (options.wrap) next = (next + count) % count; + if (next < 0 || next >= count) return null; + return order === "row-major" + ? {rowIndex: Math.floor(next / width), columnIndex: next % width} + : {rowIndex: next % width, columnIndex: Math.floor(next / width)}; +} diff --git a/packages/json-document-selection/tests/grid-traversal.test.ts b/packages/json-document-selection/tests/grid-traversal.test.ts new file mode 100644 index 000000000..3ee87efcf --- /dev/null +++ b/packages/json-document-selection/tests/grid-traversal.test.ts @@ -0,0 +1,20 @@ +import {expect, test} from "vitest"; +import {traverseGrid} from "../src/index.js"; + +test("sequential entry visits the rectangle in row or column order, including reverse wrap", () => { + for (const order of ["row-major", "column-major"] as const) { + const expected = order === "row-major" ? [[0,0],[0,1],[0,2],[1,0],[1,1],[1,2]] : [[0,0],[1,0],[0,1],[1,1],[0,2],[1,2]]; + let point = {rowIndex: 0, columnIndex: 0}; + for (let i = 1; i <= expected.length; i++) { + const next = traverseGrid(point, {rowCount: 2, columnCount: 3, order, wrap: true})!; + expect([next.rowIndex, next.columnIndex]).toEqual(expected[i % expected.length]); + expect(traverseGrid(next, {rowCount: 2, columnCount: 3, order, wrap: true, reverse: true})).toEqual(point); + point = next; + } + } +}); +test("boundaries, stale coordinates and huge sparse grids do not allocate cell sets", () => { + expect(traverseGrid({rowIndex: 0, columnIndex: 0}, {rowCount: 2, columnCount: 3, order: "row-major", reverse: true})).toBeNull(); + expect(traverseGrid({rowIndex: -1, columnIndex: 0}, {rowCount: 2, columnCount: 3, order: "row-major", wrap: true})).toBeNull(); + expect(traverseGrid({rowIndex: 999999, columnIndex: 9999}, {rowCount: 1000000, columnCount: 10000, order: "row-major", wrap: true})).toEqual({rowIndex: 0, columnIndex: 0}); +}); diff --git a/packages/json-document-sheet/docs/api-reference.md b/packages/json-document-sheet/docs/api-reference.md index 0c7a931b7..217c08bd6 100644 --- a/packages/json-document-sheet/docs/api-reference.md +++ b/packages/json-document-sheet/docs/api-reference.md @@ -6,10 +6,22 @@ Sheet와 Markdown 표의 셀 선택·편집·구조 조작 UI 조합의 public e > 이 문서는 `packages/json-document-sheet/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. +## `SheetCellEditorProps` + +```ts +interface SheetCellEditorProps { + readonly label: string; + readonly value: string; + readonly style: CSSProperties; + readonly onValueChange: (value: string) => void; + readonly onKeyDown: KeyboardEventHandler; + readonly onBlur: FocusEventHandler; +} +``` ## `SheetHand` ```ts -SheetHand({ editor, label, headerRow, renderCell, onExit }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +SheetHand({ editor, label, headerRow, profile, renderCell, renderEditor, onExit }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element ``` ## `SheetHandProps` @@ -19,7 +31,11 @@ interface SheetHandProps { readonly label?: string; /** Header row presentation only; structure restrictions belong to editor.structure. */ readonly headerRow?: boolean; + /** Document tables activate editing with Enter; spreadsheets use Enter for sequential entry. */ + readonly profile?: "document-table" | "spreadsheet-grid"; readonly onExit?: (edge: "before" | "after") => void; readonly renderCell?: (value: string) => ReactNode; + /** Format-owned editor, e.g. Markdown. Receives a draft contract, never document/history ownership. */ + readonly renderEditor?: (props: SheetCellEditorProps) => ReactNode; } ``` diff --git a/packages/json-document-sheet/docs/editing.md b/packages/json-document-sheet/docs/editing.md index d1ecfda21..ee53d181c 100644 --- a/packages/json-document-sheet/docs/editing.md +++ b/packages/json-document-sheet/docs/editing.md @@ -13,7 +13,7 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: - 클릭: 셀 선택. Shift+클릭/방향키: 범위 확장. Mod+클릭: 불연속 범위. - 방향키: 셀 이동. Tab/Shift+Tab: 다음/이전 셀. 표 경계에서는 기본 Tab 흐름. -- Enter/F2/더블클릭: 편집. 입력 중 방향키는 문자열 안에서 이동. +- F2/더블클릭: 편집. Enter는 아래 profile 정책을 따릅니다. 입력 중 방향키는 문자열 안에서 이동. - 편집 중 Enter: 확정 후 아래 셀. Escape: 취소. Tab: 확정 후 다음 셀. - 복사/잘라내기/붙여넣기: Sheet의 정본 TSV 및 structured clipboard 계약. - 행/열 추가·삭제: 단일 History transaction. `editor.structure`의 capability가 헤더 행과 최소 열 수 제한을 제공합니다. `headerRow` prop은 제목 행의 표현만 결정합니다. @@ -23,3 +23,13 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: 편집 여부에 관계없이 액션은 같은 위치의 아이콘으로 표시합니다. 입력창은 셀의 기존 글꼴·여백을 사용하며, 편집 진입·입력·취소는 표의 열 너비나 행 높이를 바꾸지 않습니다. 확정된 새 내용에 따른 표 크기 조정은 문서 변경으로 반영됩니다. 편집 초안은 Affordance의 `createRenameSession`, React 관찰은 `useRenameSession`, 키의 의미는 `cellEditingAffordance`, 입력 UI는 `Field`를 사용합니다. 확정 거절 시 초안과 위치를 유지합니다. + +## 문서 표와 기존 Sheet의 입력 정책 + +`profile="spreadsheet-grid"`가 기본값입니다. Enter/Shift+Enter는 세로 이동, F2/더블클릭은 편집 시작입니다. `profile="document-table"`에서는 Enter로 편집을 시작합니다. 편집 중 Enter/Tab은 확정 후 이동하며 사각 선택은 유지됩니다. Sheet의 Ctrl+Enter는 초안을 선택된 셀들에 한 번에 채웁니다. Shift+Space/Control+Space로 행/열을 선택할 수 있습니다. + +포인터 드래그, 행/열 헤더 선택, 채우기 핸들을 제공합니다. 채우기 핸들을 드래그하면 값 패턴을 반복하고 클릭 또는 키보드 활성화는 아래 한 행에 반복합니다. 수식 자동 보정 및 숫자 수열 생성은 이 Hand의 기본 채우기 계약에 포함되지 않습니다. + +크기를 저장할 수 있는 editor에서는 행/열 경계 리사이즈를 제공합니다. 경계 핸들은 키보드로도 조작할 수 있으며 미리보기 후 확정할 때만 History에 기록합니다. GFM editor는 이 capability를 제공하지 않습니다. + +`renderEditor`는 포맷 소유 편집기를 받을 수 있습니다. Markdown 소비자는 `MarkdownCellEditor`를 연결하므로 편집 전후 서식도 유지합니다. 단순 문자열은 기존 Field를 사용합니다. `renderCell`은 읽기 표현이며, 맞는 포맷의 `renderEditor`와 함께 사용합니다. diff --git a/packages/json-document-sheet/package.json b/packages/json-document-sheet/package.json index dedb19651..e6d531f24 100644 --- a/packages/json-document-sheet/package.json +++ b/packages/json-document-sheet/package.json @@ -39,6 +39,7 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document-selection": "^0.1.0-rc.0", "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", @@ -47,6 +48,7 @@ "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-react": "*", diff --git a/packages/json-document-sheet/src/index.ts b/packages/json-document-sheet/src/index.ts index dfe4ff79f..6fcdbcde4 100644 --- a/packages/json-document-sheet/src/index.ts +++ b/packages/json-document-sheet/src/index.ts @@ -1 +1 @@ -export { SheetHand, type SheetHandProps } from "./sheet-hand.js"; +export { SheetHand, type SheetHandProps, type SheetCellEditorProps } from "./sheet-hand.js"; diff --git a/packages/json-document-sheet/src/sheet-axis-resize.tsx b/packages/json-document-sheet/src/sheet-axis-resize.tsx new file mode 100644 index 000000000..77cc77e2e --- /dev/null +++ b/packages/json-document-sheet/src/sheet-axis-resize.tsx @@ -0,0 +1,26 @@ +import {useRef} from "react"; +import {resizeValueForKey, storedResizeValue} from "@interactive-os/json-document-affordance"; +import {useInteractionHandle} from "@interactive-os/json-document-ui-primitives-react"; + +/** Axis presentation and command wiring; gesture lifecycle and numeric rules have canonical owners. */ +export function SheetAxisResize({axis, label, onPreview, onCommit}: { + readonly axis: "x" | "y"; readonly label: string; + readonly onPreview: (size: number | null) => void; readonly onCommit: (size: number) => void; +}) { + const origin = useRef(0); + const bounds = axis === "x" ? {min:40,max:1200} : {min:24,max:1000}; + const binding = useInteractionHandle({descriptor:{kind:"resize",edge:axis === "x" ? "e" : "s"}, + onHandle(event, input) { + if (event.phase === "start") {const rect = input.currentTarget.parentElement!.getBoundingClientRect(); origin.current = axis === "x" ? rect.width : rect.height;} + const size = storedResizeValue(origin.current + (axis === "x" ? event.delta.dx : event.delta.dy), bounds); + if (event.phase === "preview") onPreview(size); + if (event.phase === "cancel") onPreview(null); + if (event.phase === "commit") {onPreview(null); if (size !== origin.current) onCommit(size);} + }}); + return