diff --git a/architecture/modules.json b/architecture/modules.json index c720af9e..a195759e 100644 --- a/architecture/modules.json +++ b/architecture/modules.json @@ -430,5 +430,16 @@ "entrypoint": "packages/json-document-sheet/src/index.ts", "subpaths": [], "referencePath": "packages/json-document-sheet/docs/api-reference.md" + }, + { + "packageName": "@interactive-os/json-document-sheet-document", + "sourceDirectory": "packages/json-document-sheet-document", + "positions": [ + "Document Types" + ], + "responsibility": "표 정본 스키마·타입·검증·생성", + "entrypoint": "packages/json-document-sheet-document/src/index.ts", + "subpaths": [], + "referencePath": "packages/json-document-sheet-document/docs/api-reference.md" } ] diff --git a/audits/document-types.json b/audits/document-types.json index f3723c67..b1546d50 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -92,6 +92,7 @@ "symbol": "CalendarDocument" }, "sheet": { + "statusNote": "표 스키마·타입·검증·생성은 Sheet Document 정본으로 이동했습니다. [소유자 API](/docs/api/sheet-document)를 Bear·Canvas·Sheet가 함께 사용합니다. Intent·변경 계획·부모 문서 연결은 Editing이 소유하며, 전체 Document Type Profile의 Stable 승격과 형제 레포 통합은 이 범위에서 선언하지 않습니다.", "why": "grid cell 값은 안정된 row·column identity와 좌표 관계를 유지해야 배열 index 변화에도 의미가 보존되기 때문입니다.", "does": "column, row와 column-keyed cell 모델을 정의하고 grid topology와 range projection의 기반을 제공합니다.", "schema": "interface SheetColumn { readonly id: string; readonly label: string }\ninterface SheetRow { readonly id: string; readonly cells: Readonly> }\ninterface SheetDocument {\n readonly columns: ReadonlyArray;\n readonly rows: ReadonlyArray;\n}", @@ -102,7 +103,7 @@ { "name": "rows[].id", "description": "selection과 range가 배열 위치와 무관하게 row를 참조하는 identity입니다." }, { "name": "rows[].cells", "description": "column id를 key로 JSON cell value를 저장합니다. 알려진 column만 key로 사용해야 합니다." } ], - "sourcePath": "packages/json-document-editing/src/sheet.ts", + "sourcePath": "packages/json-document-sheet-document/src/schema.ts", "symbol": "SheetDocument" }, "kanban": { diff --git a/package-lock.json b/package-lock.json index 769256a4..05d43cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,8 @@ "packages/json-document-collaboration", "packages/contenteditable-collaboration", "site", - "packages/json-document-sheet" + "packages/json-document-sheet", + "packages/json-document-sheet-document" ], "devDependencies": { "@playwright/test": "^1.60.0", @@ -1239,6 +1240,10 @@ "resolved": "packages/json-document-sheet", "link": true }, + "node_modules/@interactive-os/json-document-sheet-document": { + "resolved": "packages/json-document-sheet-document", + "link": true + }, "node_modules/@interactive-os/json-document-site": { "resolved": "site", "link": true @@ -6638,6 +6643,7 @@ "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-react": "*", + "@interactive-os/json-document-sheet": "*", "@interactive-os/json-document-ui-primitives-react": "*", "@interactive-os/json-document-web": "*", "@testing-library/react": "^16.3.2", @@ -6655,6 +6661,7 @@ "@interactive-os/json-document-file-intake": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-sheet": ">=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" @@ -6822,6 +6829,7 @@ "@interactive-os/json-document-markdown": "*", "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-selection": "*", + "@interactive-os/json-document-sheet-document": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" @@ -6831,7 +6839,8 @@ "@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" + "@interactive-os/json-document-selection": "^0.1.0-rc.0", + "@interactive-os/json-document-sheet-document": "^0.1.0-rc.0" } }, "packages/json-document-file-intake": { @@ -7174,6 +7183,23 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-sheet-document": { + "name": "@interactive-os/json-document-sheet-document", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document": "^3.0.0" + } + }, "packages/json-document-tanstack-table": { "name": "@interactive-os/json-document-tanstack-table", "version": "0.1.0-rc.0", @@ -7313,6 +7339,7 @@ "zod": "^4.0.0" }, "devDependencies": { + "@interactive-os/json-document-sheet-document": "*", "@tanstack/router-plugin": "^1.168.30", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/package.json b/package.json index 02a57282..4f6ce570 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "packages/json-document-collaboration", "packages/contenteditable-collaboration", "site", - "packages/json-document-sheet" + "packages/json-document-sheet", + "packages/json-document-sheet-document" ], "scripts": { "dev": "npm run dev -w @interactive-os/json-document-site", diff --git a/packages/json-document-affordance/docs/api-reference.md b/packages/json-document-affordance/docs/api-reference.md index 6e01ffb4..50a9ffe9 100644 --- a/packages/json-document-affordance/docs/api-reference.md +++ b/packages/json-document-affordance/docs/api-reference.md @@ -548,6 +548,20 @@ interface GestureState { readonly type: string; } ``` +## `GridEditingProfile` + +```ts +interface GridEditingProfile { + readonly enter: "edit" | "move"; + /** Existing-content activation (F2/double click); replacement typing always places the caret at the end. */ + readonly editSelection: "all" | "end"; +} +``` +## `gridEditingProfiles` + +```ts +const gridEditingProfiles: Readonly> +``` ## `GridFillBounds` ```ts @@ -864,7 +878,7 @@ renameAffordance(input: Pick | { readonly type: "point ```ts interface RenameSession { getSnapshot(): RenameSessionSnapshot | null; - begin(key: Key, label: string): void; + begin(key: Key, label: string, initialSelection?: "all" | "end"): void; update(draft: string): void; handleKey(key: string): boolean; handlePointer(key: Key, label: string, detail: number, timeStamp: number): boolean; @@ -878,6 +892,7 @@ interface RenameSession { interface RenameSessionSnapshot { readonly key: Key; readonly draft: string; + readonly initialSelection?: "all" | "end"; } ``` ## `resizeAffordance` @@ -917,6 +932,11 @@ resizeValueForKey(current: number, key: string, shiftKey: boolean, axis: "x" | " ```ts resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview ``` +## `resolveGridEditActivation` + +```ts +resolveGridEditActivation(profile: GridEditingProfile, existingText: string, replacementText?: string): { draft: string; initialSelection: "all" | "end"; } +``` ## `selectAllAffordance` ```ts diff --git a/packages/json-document-affordance/docs/cell-editing.md b/packages/json-document-affordance/docs/cell-editing.md index 9a3f969a..fcd2acc6 100644 --- a/packages/json-document-affordance/docs/cell-editing.md +++ b/packages/json-document-affordance/docs/cell-editing.md @@ -5,3 +5,5 @@ 편집 중 일반 문자·방향키·Mod+A는 native field에 남깁니다. `rename` hand의 `initialText`는 입력으로 시작하는 초안, `move`는 성공적인 확정 이후 이동 방향입니다. 조합 중 키는 Web owner의 `isWebComposingKey`로 먼저 제외합니다. [Sheet Usage](/demo/sheet)에서 `useRenameSession`과 함께 사용합니다. 초안 확정이 거절되면 이동하지 않고 초안을 유지해야 합니다. + +`gridEditingProfiles["spreadsheet-mac"]`은 선택 상태에서 Enter로 편집을 시작합니다. 편집 중 Enter는 확정 후 아래로 이동합니다. [Sheet Views Usage](/demo/sheet-views)에서 SheetHand는 Mac의 기본 spreadsheet-grid에 이 정책을 적용하며, 명시한 프로파일 객체는 그대로 사용합니다. diff --git a/packages/json-document-affordance/docs/grid-interaction.md b/packages/json-document-affordance/docs/grid-interaction.md index 8d092908..e153a6a5 100644 --- a/packages/json-document-affordance/docs/grid-interaction.md +++ b/packages/json-document-affordance/docs/grid-interaction.md @@ -13,3 +13,11 @@ const width=storedResizeValue(143.6,{min:40,max:1200}); ``` [Sheet Usage](/demo/sheet)의 채우기 핸들과 행열 리사이즈에서 확인할 수 있습니다. 드래그 미리보기는 문서를 변경하지 않고 확정 시 Editing Intent 하나를 실행합니다. 기존 Sheet의 수식 및 수열 채우기는 해당 Sheet 엔진에 유지되며, JSON/Markdown Hand의 `range.fill`은 원본 값 패턴을 반복 복제합니다. + +## 표 입력 프로파일 + +`GridEditingProfile`과 `gridEditingProfiles`는 배치 환경과 독립인 입력 정책입니다. `document-table`은 Enter로 편집하고 `spreadsheet-grid`는 Enter로 이동합니다. `editSelection`은 F2/더블클릭으로 기존 내용을 편집할 때 전체 선택할지 끝에 둘지 정합니다. 직접 타이핑은 기존 값을 대체하며 첫 글자 뒤에서 이어 씁니다. `createRenameSession.begin`의 `initialSelection`이 이 결정을 초안과 함께 전달합니다. + +Canvas·Bear는 별도 입력 프로파일 이름이 아닙니다. 문서 형식의 크기 저장 가능 여부는 Editing capability, 외부로 나가는 포커스는 Hand의 `onExit`/`onDeactivate`, 화면 좌표 변환은 Web이 각각 소유합니다. `cellEditingAffordance`는 초안이 없는 Escape를 `cancel`로 내보내므로 배치 환경이 외부 편집기로 복귀시킬 수 있습니다. + +`resolveGridEditActivation(profile, existingText, replacementText?)`가 초안과 초기 선택을 함께 계산합니다. Hand는 이를 RenameSession에 전달할 뿐 첫 입력/F2의 정책을 다시 판단하지 않습니다. diff --git a/packages/json-document-affordance/src/cell-editing.ts b/packages/json-document-affordance/src/cell-editing.ts index 2b897eb4..ad310561 100644 --- a/packages/json-document-affordance/src/cell-editing.ts +++ b/packages/json-document-affordance/src/cell-editing.ts @@ -18,6 +18,7 @@ export function cellEditingAffordance(stroke: WebKeyboardStroke, state: {readonl if (action === "enter" || action === "previous") return {hand: {type: "rename", action: "commit", move: action === "previous" ? "up" : "down"}}; return {hand: null}; } + if (action === "cancel") return {hand:{type:"cancel"}}; 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"}}; diff --git a/packages/json-document-affordance/src/grid-editing-profile.ts b/packages/json-document-affordance/src/grid-editing-profile.ts new file mode 100644 index 00000000..2291bca3 --- /dev/null +++ b/packages/json-document-affordance/src/grid-editing-profile.ts @@ -0,0 +1,16 @@ +/** Input policy is independent of the document format and its embedding environment. */ +export interface GridEditingProfile { + readonly enter: "edit" | "move"; + /** Existing-content activation (F2/double click); replacement typing always places the caret at the end. */ + readonly editSelection: "all" | "end"; +} +export const gridEditingProfiles: Readonly> = { + "document-table": {enter:"edit",editSelection:"all"}, + "spreadsheet-mac": {enter:"edit",editSelection:"all"}, + "spreadsheet-grid": {enter:"move",editSelection:"all"}, +}; + +/** Replacement typing and existing-content editing have distinct initial caret contracts. */ +export function resolveGridEditActivation(profile: GridEditingProfile, existingText: string, replacementText?: string): {draft: string; initialSelection: "all" | "end"} { + return {draft:replacementText ?? existingText,initialSelection:replacementText === undefined ? profile.editSelection : "end"}; +} diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index 0c7c67f9..ebbd18e5 100644 --- a/packages/json-document-affordance/src/index.ts +++ b/packages/json-document-affordance/src/index.ts @@ -158,3 +158,4 @@ export { clampResizeValue, storedResizeValue, resizeValueForKey, collapseResizeV export type { ResizeBounds } from "./axis-resize.js"; export { extendGridFill } from "./grid-fill.js"; export type { GridFillBounds } from "./grid-fill.js"; +export {gridEditingProfiles, resolveGridEditActivation, type GridEditingProfile} from "./grid-editing-profile.js"; diff --git a/packages/json-document-affordance/src/session.ts b/packages/json-document-affordance/src/session.ts index da75e6bc..1e176db7 100644 --- a/packages/json-document-affordance/src/session.ts +++ b/packages/json-document-affordance/src/session.ts @@ -67,11 +67,12 @@ export function createTypeaheadSession(options: { export interface RenameSessionSnapshot { readonly key: Key; readonly draft: string; + readonly initialSelection?: "all" | "end"; } export interface RenameSession { getSnapshot(): RenameSessionSnapshot | null; - begin(key: Key, label: string): void; + begin(key: Key, label: string, initialSelection?: "all" | "end"): void; update(draft: string): void; handleKey(key: string): boolean; handlePointer(key: Key, label: string, detail: number, timeStamp: number): boolean; @@ -111,8 +112,8 @@ export function createRenameSession(options: ({ } return { getSnapshot: () => snapshot, - begin(key, label) { - publish({ key, draft: label }); + begin(key, label, initialSelection) { + publish({ key, draft: label, ...(initialSelection ? {initialSelection} : {}) }); }, update(draft) { if (snapshot !== null) publish({ ...snapshot, draft }); diff --git a/packages/json-document-affordance/tests/grid-editing-profile.test.ts b/packages/json-document-affordance/tests/grid-editing-profile.test.ts new file mode 100644 index 00000000..243293d1 --- /dev/null +++ b/packages/json-document-affordance/tests/grid-editing-profile.test.ts @@ -0,0 +1,8 @@ +import {expect,test} from "vitest"; +import {cellEditingAffordance,createRenameSession,gridEditingProfiles} from "../src/index.js"; +test("input policy distinguishes activation from replacement and outer cancellation",()=>{ + expect(gridEditingProfiles['document-table'].enter).toBe('edit'); + expect(cellEditingAffordance({key:'Escape',shiftKey:false,metaKey:false,ctrlKey:false},{editing:false,allSelected:false}).hand).toEqual({type:'cancel'}); + expect(cellEditingAffordance({key:'a',shiftKey:false,metaKey:false,ctrlKey:false},{editing:false,allSelected:false}).hand).toMatchObject({type:'rename',initialText:'a'}); + const rename=createRenameSession({onCommit:()=>{}});rename.begin('cell','a','end');rename.update('ab');expect(rename.getSnapshot()).toEqual({key:'cell',draft:'ab',initialSelection:'end'}); +}); diff --git a/packages/json-document-canvas/docs/api-reference.md b/packages/json-document-canvas/docs/api-reference.md index 0ae2f5e8..094191f0 100644 --- a/packages/json-document-canvas/docs/api-reference.md +++ b/packages/json-document-canvas/docs/api-reference.md @@ -46,10 +46,15 @@ interface CanvasHandProps { readonly selectProfile?: PlaneSelectProfile; } ``` +## `CanvasSheetObject` + +```ts +CanvasSheetObject({ object, editor, active, onDeactivate }: { readonly object: Extract; readonly editor: ObjectEditor; readonly active: boolean; readonly onDeactivate: () => void; }): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` ## `CanvasTool` ```ts -type CanvasTool = "select" | Exclude; +type CanvasTool = "select" | "table" | Exclude; ``` ## `createCanvasClipboardBinding` @@ -59,5 +64,5 @@ createCanvasClipboardBinding(editor: ObjectEditor, policy: CanvasClipboardPolicy ## `useCanvasHand` ```ts -useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile): { document: CanvasDocument; snapshot: import("/packages/json-document-editing/src/session").EditingSnapshot; ... 23 more ...; surfaceProps: { ...; }; } +useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile, activateEmbedded?: (objectId: string) => void): { document: CanvasDocument; ... 24 more ...; surfaceProps: { ...; }; } ``` diff --git a/packages/json-document-canvas/docs/embedded-sheet.md b/packages/json-document-canvas/docs/embedded-sheet.md new file mode 100644 index 00000000..0706cb76 --- /dev/null +++ b/packages/json-document-canvas/docs/embedded-sheet.md @@ -0,0 +1,24 @@ +# Canvas 안의 표 + +Canvas 도구 모음의 표 아이콘으로 객체를 생성합니다. 클릭은 기본 크기, 드래그는 지정한 크기로 만듭니다. 객체 선택 상태에서는 이동·리사이즈·복제·삭제가 작동하고 더블클릭·F2 또는 선택 타깃의 Enter로 내부 셀 편집에 들어갑니다. 셀 편집 중 Escape는 초안을 취소하고, 셀 선택 상태의 Escape는 Canvas 객체 선택으로 돌아갑니다. 표 끝의 Tab/Shift+Tab도 바깥으로 돌아갑니다. + +내부 UI는 Bear와 Sheet 앱이 소비하는 `SheetHand`입니다. Canvas가 별도의 셀 키보드·선택·클립보드 구현을 갖지 않습니다. `CanvasSheetObject`는 SVG foreignObject 프레임, 활성화와 포커스 연결만 조합하며, 활성 여부가 표의 모양과 크기를 바꾸지 않습니다. + +```tsx +import {createCanvasSheet, createObjectEditor} from '@interactive-os/json-document-editing'; +import {CanvasHand} from '@interactive-os/json-document-canvas'; + +const table = createCanvasSheet({x:100, y:100, width:480, height:280}); +const editor = createObjectEditor({profile:'canvas/1',width:1280,height:720,objects:[]}); +editor.dispatch({type:'object.create',object:table}); +// +``` + +- Object Document 소유자는 `kind: "embedded-document"`, `documentType`, `document`로 공간 객체와 내장 문서의 경계를 정의합니다. 표 데이터 모델은 중복 정의하지 않습니다. +- Editing의 `createCanvasSheet`가 `sheet/1` 문서를 생성하고, `createObjectSheetEditor`가 부모의 해당 객체에 연결합니다. +- `createProjectedSheetEditor`는 Markdown 표와 Object 표에 공통인 projection·선택·부모 History 연결을 소유합니다. 표 명령 하나는 부모 문서 transaction 하나가 됩니다. +- 바깥 객체 이동/리사이즈와 내부 행열 리사이즈는 별개입니다. 내부 크기는 layout 좌표로 저장하므로 SVG 화면 배율이 저장값에 섞이지 않습니다. + +표 내용은 Canvas JSON 안에 저장되며 JSON 재열기·객체 복제·복사/붙여넣기·삭제/Undo에서도 보존됩니다. 알 수 없는 embedded documentType은 임의 편집기를 만들지 않고 지원하지 않는 문서로 표시합니다. Object 소유자는 내장 payload를 보존하며 payload의 구체 모델 검증은 각 편집 어댑터가 소유합니다. + +현재 좌표 보정은 Canvas의 축 정렬 SVG viewport 및 CSS 확대/축소를 대상으로 합니다. 회전/기울이기와 수식, Excel 파일 호환성은 지원 범위가 아닙니다. 실제 Usage는 `/demo/canvas`와 `/widgets/canvas`입니다. diff --git a/packages/json-document-canvas/package.json b/packages/json-document-canvas/package.json index 50302265..a4e9aaf0 100644 --- a/packages/json-document-canvas/package.json +++ b/packages/json-document-canvas/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "lucide-react": "^1.33.0" }, "peerDependencies": { + "@interactive-os/json-document-sheet": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-file-intake": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", @@ -25,6 +26,7 @@ "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document-sheet": "*", "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-web": "*", diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx index 9e7647a5..3495c2e1 100644 --- a/packages/json-document-canvas/src/canvas-hand.tsx +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -1,5 +1,7 @@ -import { useState, type CSSProperties } from "react"; -import { Braces, Circle, CopyPlus, MousePointer2, Pencil, RectangleHorizontal, Redo2, StickyNote, Trash2, Type, Undo2, type LucideIcon } from "lucide-react"; +import {CanvasSheetObject} from "./canvas-sheet-object.js"; +import {sheetEmbeddedDocumentType} from "@interactive-os/json-document-editing"; +import { useState, useEffect, type CSSProperties } from "react"; +import { Braces, Circle, CopyPlus, MousePointer2, Pencil, RectangleHorizontal, Redo2, StickyNote, Trash2, Type, Table2, Undo2, type LucideIcon } from "lucide-react"; import type { ObjectEditor } from "@interactive-os/json-document-editing"; import type { PlaneSelectProfile } from "@interactive-os/json-document-affordance"; import { serializeCanvasDocument } from "@interactive-os/json-document-object-document"; @@ -21,13 +23,24 @@ export interface CanvasHandProps { const tools: ReadonlyArray<{ readonly id: CanvasTool; readonly label: string; readonly icon: LucideIcon }> = [ { id: "select", label: "선택", icon: MousePointer2 }, { id: "text", label: "글자", icon: Type }, { id: "sticky-note", label: "스티커 노트", icon: StickyNote }, + { id:"table",label:"표",icon:Table2 }, { id: "rectangle", label: "사각형", icon: RectangleHorizontal }, { id: "ellipse", label: "타원", icon: Circle }, { id: "path", label: "그리기", icon: Pencil }, ]; export function CanvasHand(props: CanvasHandProps) { - const hand = useCanvasHand(props.editor, props.creationStyle, props.selectProfile); + const [editingObject, setEditingObject] = useState(null); + const hand = useCanvasHand(props.editor, props.creationStyle, props.selectProfile, setEditingObject); const [json, setJSON] = useState(null); const selected = hand.objects.find((object) => object.id === hand.selection.primaryKey); + useEffect(() => {if(hand.tool !== "select" || selected?.id !== editingObject) setEditingObject(null);},[hand.tool,selected?.id,editingObject]); + const exitTable = () => {setEditingObject(null);hand.surface.current?.focus();}; + const editObject = (object: typeof hand.objects[number]) => { + if(object.kind === "embedded-document" && object.documentType === sheetEmbeddedDocumentType) {hand.select(object.id);setEditingObject(object.id);} + else hand.editText(object.id); + }; + const renderEmbedded = (object: Extract) => object.documentType === sheetEmbeddedDocumentType + ? + :
지원하지 않는 문서: {object.documentType}
; const selectedKeys = new Set(hand.selection.keys); const copyOriginals = new Map(hand.copyOriginals.map((object) => [object.id, object])); return ( @@ -42,22 +55,22 @@ export function CanvasHand(props: CanvasHandProps) { {hand.tool === "select" && { hand.commitText(); hand.cancel(); }} />} { hand.commitText(); hand.cancel(); setJSON(json === null ? serializeCanvasDocument(props.editor.snapshot.value as typeof hand.document) : null); }}> }> - {if(editingObject && !(event.target as Element).closest("[data-canvas-sheet]")) setEditingObject(null);}} tabIndex={0} role="group" aria-label={props.label ?? "Canvas slide"} aria-busy={hand.pastePending} data-canvas-slide="true" data-tool={hand.tool} viewBox={`0 0 ${hand.document.width} ${hand.document.height}`} preserveAspectRatio="none" style={{ display: "block", width: "100%", aspectRatio: `${hand.document.width} / ${hand.document.height}`, touchAction: "none", userSelect: "none", overflow: "hidden", ...props.slideStyle }}> {hand.objects.map((object) => - - + 0 && selectedKeys.has(object.id)} - onSelect={(shiftKey) => hand.select(object.id, shiftKey)} onEdit={() => hand.editText(object.id)} onHandle={(interaction, event) => hand.interaction(interaction, event, object, "drag")} /> + onSelect={(shiftKey) => {setEditingObject(null);hand.select(object.id, shiftKey);}} onEdit={() => editObject(object)} onHandle={(interaction, event) => hand.interaction(interaction, event, object, "drag")} /> )} - {copyOriginals.size > 0 && {hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )}} - {hand.preview && } + {copyOriginals.size > 0 && {hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )}} + {hand.preview && } {hand.tool === "select" && hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )} {selected && hand.tool === "select" && - {!hand.draft && (["n", "e", "s", "w", "nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} + {!hand.draft && !editingObject && (["n", "e", "s", "w", "nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} } {hand.marquee && } {selected && hand.draft?.id === selected.id && ) => ReactNode }): ReactNode { + if (object.kind === "embedded-document" && renderEmbedded) return renderEmbedded(object); const style = getObjectStyle(object); if (object.kind === "image") return ; if (object.kind === "path") { diff --git a/packages/json-document-canvas/src/canvas-sheet-object.tsx b/packages/json-document-canvas/src/canvas-sheet-object.tsx new file mode 100644 index 00000000..97667acd --- /dev/null +++ b/packages/json-document-canvas/src/canvas-sheet-object.tsx @@ -0,0 +1,23 @@ +import {useEffect,useMemo,useRef} from "react"; +import {createObjectSheetEditor,createSheetEditor,type ObjectEditor,type SheetDocument} from "@interactive-os/json-document-editing"; +import type {CanvasObject} from "@interactive-os/json-document-object-document"; +import {SheetHand} from "@interactive-os/json-document-sheet"; +import {findWebGridCell} from "@interactive-os/json-document-web"; + +/** Canvas owns the spatial frame and activation; Sheet owns every cell interaction. */ +export function CanvasSheetObject({object,editor,active,onDeactivate}:{ + readonly object:Extract; + readonly editor:ObjectEditor; + readonly active:boolean; + readonly onDeactivate:()=>void; +}) { + const root=useRef(null); + const persisted=(editor.snapshot.value as {objects:ReadonlyArray<{id:string}>}).objects.some(item=>item.id === object.id); + const sheet=useMemo(()=>persisted ? createObjectSheetEditor(editor,object.id) : createSheetEditor(object.document as SheetDocument),[editor,object.id,persisted]); + useEffect(()=>{const point=sheet.snapshot.selection.focus;if(active && point)findWebGridCell(root.current,point)?.focus({preventScroll:true});},[active,sheet]); + return +
+ +
+
; +} diff --git a/packages/json-document-canvas/src/index.ts b/packages/json-document-canvas/src/index.ts index 4a7e72cf..ed889c81 100644 --- a/packages/json-document-canvas/src/index.ts +++ b/packages/json-document-canvas/src/index.ts @@ -1,3 +1,4 @@ export { CanvasHand, type CanvasHandProps } from "./canvas-hand.js"; export { createCanvasClipboardBinding, type CanvasClipboardPolicy } from "./canvas-clipboard.js"; export { useCanvasHand, type CanvasTool, type CanvasCreationStyle } from "./use-canvas-hand.js"; +export {CanvasSheetObject} from "./canvas-sheet-object.js"; diff --git a/packages/json-document-canvas/src/use-canvas-hand.ts b/packages/json-document-canvas/src/use-canvas-hand.ts index 48aff1f8..c735ac98 100644 --- a/packages/json-document-canvas/src/use-canvas-hand.ts +++ b/packages/json-document-canvas/src/use-canvas-hand.ts @@ -1,3 +1,4 @@ +import {createCanvasSheet} from "@interactive-os/json-document-editing"; import { useEffect, useMemo, useReducer, useRef, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from "react"; import { commitAffordance, createGestureSession, createPlaneSelectProfile, resizeAffordance, type InteractionHandleEvent, type PlaneSelectProfile, type PlaneSelectSelection, type ResizeEdge } from "@interactive-os/json-document-affordance"; import { assertCanvasDocument, createCanvasObject, createCanvasPath, parseCanvasDocument, projectObjectText, readObjectStyle, transformObject, type CanvasDocument, type CanvasObject, type CanvasObjectKind, type ObjectPoint, type ObjectStyle } from "@interactive-os/json-document-object-document"; @@ -6,7 +7,7 @@ import { useEditingSnapshot } from "@interactive-os/json-document-react"; import { routeWebClipboardEvent, createWebKeyboardAdapter, createWebPointerSession, isWebEditableTarget, projectWebClientPointToSVG, webSVGViewportFromElement } from "@interactive-os/json-document-web"; import { createCanvasClipboardBinding } from "./canvas-clipboard.js"; -export type CanvasTool = "select" | Exclude; +export type CanvasTool = "select" | "table" | Exclude; export interface CanvasCreationStyle { readonly color: string; readonly textColor: string; @@ -17,7 +18,7 @@ export interface CanvasCreationStyle { } type Gesture = { readonly base: CanvasDocument } & ( - | { readonly type: "create"; readonly tool: Exclude; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly dragged: boolean } + | { readonly type: "create"; readonly tool: "table" | Exclude; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly dragged: boolean } | { readonly type: "draw"; readonly points: ReadonlyArray } | { readonly type: "resize"; readonly object: CanvasObject; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly edge: ResizeEdge; readonly pointerId: number; readonly shiftKey: boolean; readonly altKey: boolean; readonly selection: ObjectSelection } @@ -28,7 +29,7 @@ const keyboard = createWebKeyboardAdapter(); const commands = createWebKeyboardAdapter<"cancel">({ defaults: false, keymap: { Escape: "cancel" } }); /** Owns Canvas interaction composition, never document or history state. */ -export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile) { +export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile, activateEmbedded?: (objectId: string) => void) { const snapshot = useEditingSnapshot(editor); const document = useMemo(() => { assertCanvasDocument(snapshot.value); return snapshot.value; }, [snapshot.value]); const surface = useRef(null); @@ -149,6 +150,7 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, // A default-sized object belongs to the completed click, never its press preview. if (click && !committing) return null; if (!click && start.x === point.x && start.y === point.y) return null; + if (gesture.tool === "table") return createCanvasSheet({x:click ? start.x:Math.min(start.x,point.x),y:click ? start.y:Math.min(start.y,point.y),width:click ? 480:Math.abs(point.x-start.x),height:click ? 280:Math.abs(point.y-start.y)}); return createCanvasObject(gesture.tool, { x: click ? start.x : Math.min(start.x, point.x), y: click ? start.y : Math.min(start.y, point.y), width: click ? (gesture.tool === "text" ? 280 : gesture.tool === "sticky-note" ? 200 : 160) : Math.abs(point.x - start.x), @@ -270,7 +272,10 @@ export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, else if (selectedAction.type === "delete") remove(); else if (selectedAction.type === "duplicate") duplicate(selectedAction.keys); else if (selectedAction.type === "translate") dispatch({ type: "object.translate", objectIds: selectedAction.keys, dx: selectedAction.dx, dy: selectedAction.dy }); - else if (selectedAction.type === "edit") editText(selectedAction.key); + else if (selectedAction.type === "edit") { + const object=current().objects.find(object=>object.id === selectedAction.key); + if(object?.kind === "embedded-document") activateEmbedded?.(object.id);else editText(selectedAction.key); + } setTool("select"); return; } if (commands.resolve(event) === "cancel") { event.preventDefault(); setTool("select"); return; } diff --git a/packages/json-document-canvas/tests/canvas-hand.test.tsx b/packages/json-document-canvas/tests/canvas-hand.test.tsx index 649a5fcc..1fb0524d 100644 --- a/packages/json-document-canvas/tests/canvas-hand.test.tsx +++ b/packages/json-document-canvas/tests/canvas-hand.test.tsx @@ -331,7 +331,7 @@ test("external selection changes supersede a pending preview without a stale sel test("every toolbar control shares icon, accessible name and canonical tooltip without losing state", () => { const { svg } = setup(); const toolbar = within(screen.getByRole("toolbar", { name: "Canvas tools" })); - const labels = ["선택", "글자", "스티커 노트", "사각형", "타원", "그리기", "실행 취소", "다시 실행", "복제", "삭제", "JSON"]; + const labels = ["선택", "글자", "스티커 노트", "표", "사각형", "타원", "그리기", "실행 취소", "다시 실행", "복제", "삭제", "JSON"]; expect(toolbar.getAllByRole("button")).toHaveLength(labels.length); for (const label of labels) { const button = toolbar.getByRole("button", { name: label }); @@ -772,3 +772,17 @@ test.each(["", "끝의 빈 줄\n", "줄\n\n"])("body display and native input me fireEvent.keyDown(input, { key: "Enter", metaKey: true }); expect(value().objects[0]).toEqual(object); expect(commits).not.toHaveBeenCalled(); }); + +test.each([[120,50],[40,110]])("table creation accepts an axis-aligned drag through preview and commit (%s,%s)",(x,y)=>{ + const {svg,value,commits,editor}=setup(); + fireEvent.click(screen.getByRole('button',{name:'표'})); + fireEvent.pointerDown(svg,event(40,50)); + fireEvent.pointerMove(svg,event(x,y)); + expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(svg,event(x,y)); + expect(value().objects).toHaveLength(1); + expect(value().objects[0]!.width).toBeGreaterThan(0); + expect(value().objects[0]!.height).toBeGreaterThan(0); + expect(commits).toHaveBeenCalledTimes(1); + act(()=>{editor.undo();});expect(value().objects).toHaveLength(0); +}); diff --git a/packages/json-document-canvas/tsconfig.json b/packages/json-document-canvas/tsconfig.json index d561a035..86bbb2e5 100644 --- a/packages/json-document-canvas/tsconfig.json +++ b/packages/json-document-canvas/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig/library-react.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ + { "path": "../json-document-sheet" }, { "path": "../json-document-file-intake" }, { "path": "../json-document-object-document" }, { "path": "../json-document-editing" }, { "path": "../json-document-affordance" }, { "path": "../json-document-web" }, diff --git a/packages/json-document-editing/docs/api-reference.md b/packages/json-document-editing/docs/api-reference.md index fbdc4fe3..944d4d67 100644 --- a/packages/json-document-editing/docs/api-reference.md +++ b/packages/json-document-editing/docs/api-reference.md @@ -581,6 +581,11 @@ createCalendarEditor(source: EditingDocumentSource, options?: ```ts createCanvasClipboard(content: CanvasClipboardContent, options: CanvasClipboardOptions): ObjectClipboard ``` +## `createCanvasSheet` + +```ts +createCanvasSheet(bounds: ObjectBounds, options?: { rows?: number; columns?: number; }): CanvasObjectDraft +``` ## `createDatabaseEditor` ```ts @@ -631,11 +636,21 @@ createObjectEditor(source: EditingDocumentSource, options?: Edit ```ts createObjectPasteSession(editor: ObjectEditor, options?: { readonly placement?: ObjectPastePlacement; readonly onResult?: (result: EditingResult) => void; readonly onPendingChange?: (pending: boolean) => void; }): ObjectPasteSession ``` +## `createObjectSheetEditor` + +```ts +createObjectSheetEditor(parent: ObjectEditor, objectId: string): SheetEditor +``` ## `createOrderEditor` ```ts createOrderEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): OrderEditor ``` +## `createProjectedSheetEditor` + +```ts +createProjectedSheetEditor(options: ProjectedSheetOptions): SheetEditor +``` ## `createSheetEditor` ```ts @@ -1317,6 +1332,7 @@ type ObjectIntent = | { readonly type: "object.create"; readonly object: ObjectDraft } | { readonly type: "object.duplicate"; readonly objectIds: ReadonlyArray; readonly placement?: ObjectPastePlacement } | { readonly type: "object.remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "object.document"; readonly objectId: string; readonly document: JSONValue } | { readonly type: "object.text"; readonly objectId: string; readonly text: string } | { readonly type: "document.replace"; readonly document: ObjectDocument } | { @@ -1470,11 +1486,21 @@ interface OrderSelection extends Record { ```ts parseCalendarView(value: unknown): CalendarView | null ``` +## `parseSheetClipboardText` + +```ts +parseSheetClipboardText(text: string): SheetClipboard | null +``` ## `planCalendarSelectionMove` ```ts planCalendarSelectionMove(events: ReadonlyArray, occurrences: ReadonlyArray, anchor: CalendarOccurrencePoint, target: CalendarSelectionMoveTarget, options?: { readonly scope?: "this" | "this-and-following" | "all"; readonly createId?: () => string; readonly primary?: CalendarOccurrencePoint; }): CalendarSelectionMovePlan ``` +## `planSheetIntent` + +```ts +planSheetIntent(document: SheetDocument, selection: SheetSelection, intent: SheetIntent, options?: SheetEditorOptions): SheetPlanResult +``` ## `previewCalendarAllDay` ```ts @@ -1495,11 +1521,45 @@ previewCalendarTimeGrid(events: ReadonlyArray, release: CalendarT ```ts projectCalendarOccurrences(events: ReadonlyArray, rangeStart: string, rangeEnd: string): ReadonlyArray ``` +## `ProjectedSheetOptions` + +```ts +interface ProjectedSheetOptions { + readonly source: ProjectedSheetSource; + /** Null means absent; a present value is validated by the canonical Sheet schema. */ + readonly read: () => unknown; + readonly write: (value: SheetDocument) => {readonly ok:boolean;readonly code?:string}; + readonly readOnly?:()=>boolean; + readonly sheet?: SheetEditorOptions; + /** Formats with position-based IDs remap selection after structural edits. */ + readonly mapSelection?: (selection:SheetSelection,value:SheetDocument) => SheetSelection; +} +``` +## `ProjectedSheetSource` + +```ts +interface ProjectedSheetSource { + readonly snapshot: {readonly value: JSONValue;readonly canUndo: boolean;readonly canRedo: boolean}; + subscribe(listener: () => void): () => void; + undo(): {readonly ok:boolean;readonly code?:string}; + redo(): {readonly ok:boolean;readonly code?:string}; +} +``` +## `projectSheetGrid` + +```ts +projectSheetGrid(document: SheetDocument, order?: SheetViewOptions): SheetGrid +``` ## `projectTreeVisibility` ```ts projectTreeVisibility(nodes: ReadonlyArray, expandedIds: ReadonlySet): TreeVisibility ``` +## `SheetAvailability` + +```ts +type SheetAvailability = "ready" | "missing" | "invalid" | "readonly"; +``` ## `SheetCell` ```ts @@ -1524,10 +1584,7 @@ const sheetClipboardFormat: { mimeType: "application/vnd.interactive-os.sheet+js ## `SheetColumn` ```ts -interface SheetColumn extends Record { - readonly id: string; - readonly label: string; -} +type SheetColumn = z.infer; ``` ## `sheetColumnLabel` @@ -1537,15 +1594,15 @@ sheetColumnLabel(index: number): string ## `SheetDocument` ```ts -interface SheetDocument extends Record { - readonly columns: ReadonlyArray; - readonly rows: ReadonlyArray; -} +type SheetDocument = z.infer; ``` ## `SheetEditor` ```ts interface SheetEditor { + readonly availability: SheetAvailability; + readonly grid: SheetGrid; + createView(options?:SheetViewOptions):SheetEditor; readonly capabilities: {readonly resize: boolean}; readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; @@ -1562,7 +1619,7 @@ interface SheetEditor { ## `SheetEditorOptions` ```ts -interface SheetEditorOptions extends EditingHistoryOptions { +interface SheetEditorOptions extends EditingHistoryOptions, SheetViewOptions { /** False for formats such as GFM that cannot persist row heights or column widths. */ readonly resize?: boolean; readonly structure?: SheetStructurePolicy; @@ -1570,6 +1627,19 @@ interface SheetEditorOptions extends EditingHistoryOptions { readonly selection?: SheetSelection; } ``` +## `sheetEmbeddedDocumentType` + +```ts +const sheetEmbeddedDocumentType: "sheet/1" +``` +## `SheetGrid` + +```ts +interface SheetGrid extends GridTopology { + readonly rows:ReadonlyArray; + readonly columns:ReadonlyArray; +} +``` ## `SheetIntent` ```ts @@ -1581,7 +1651,7 @@ type SheetIntent = | { 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: "range.fill"; readonly source: SheetRange; readonly target: SheetRange; readonly topology?:SheetTopology } | { readonly type: "selection.navigate"; readonly direction: SheetTraversalDirection; readonly topology?: SheetTopology } | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { @@ -1613,6 +1683,11 @@ type SheetIntent = ```ts sheetNavigationTarget(topology: GridTopology, selection: SheetSelection, direction: SheetTraversalDirection): { readonly point: GridPoint; readonly preserveRange: boolean; } | null ``` +## `SheetPlanResult` + +```ts +type SheetPlanResult = {readonly ok:true;readonly plan:EditingPlan} | {readonly ok:false;readonly code:string}; +``` ## `SheetPoint` ```ts @@ -1632,10 +1707,7 @@ interface SheetRange extends Record { ## `SheetRow` ```ts -interface SheetRow extends Record { - readonly id: string; - readonly cells: Readonly>; -} +type SheetRow = z.infer; ``` ## `SheetSelection` @@ -1692,6 +1764,16 @@ type SheetTopology = GridTopology; ```ts type SheetTraversalDirection = "previous" | "next" | "up" | "down"; ``` +## `SheetViewOptions` + +```ts +interface SheetViewOptions { + readonly rowOrder?:ReadonlyArray; + readonly columnOrder?:ReadonlyArray; + readonly readOnly?:()=>boolean; + readonly selection?:SheetSelection; +} +``` ## `TextChange` ```ts diff --git a/packages/json-document-editing/docs/sheet-contract.md b/packages/json-document-editing/docs/sheet-contract.md new file mode 100644 index 00000000..e159ff37 --- /dev/null +++ b/packages/json-document-editing/docs/sheet-contract.md @@ -0,0 +1,53 @@ +# 하나의 Sheet 편집 계약 + +Sheet Document 패키지가 공통 스키마와 생성 책임을 소유합니다. Editing의 Sheet 타입 +export는 같은 타입을 재노출합니다. Bear·Canvas·독립 Sheet에 별도 문서 모델을 두지 않습니다. + +`planSheetIntent(document, selection, intent, options)`는 변경 계획과 다음 선택을 +계산하며 입력 문서 변경, 구독 알림, History 기록을 하지 않습니다. 내부 공통 명령 +해석은 독립 SheetEditor와 부모 문서 연결도 함께 사용합니다. 선택 전이는 빈 operations를 +반환하고 구조 정책·크기 저장 지원 여부도 같은 경로에서 검사합니다. + +```ts +import {createSheetDocument} from "@interactive-os/json-document-sheet-document"; +import {createSheetEditor, planSheetIntent} from "@interactive-os/json-document-editing"; +const value = createSheetDocument(); +const editor = createSheetEditor(value); +const planned = planSheetIntent(value, editor.snapshot.selection, + {type: "cell.commit", rowId: "row-1", columnId: "column-1", value: "hello"}); +``` + +`createProjectedSheetEditor`는 read/write/subscribe와 부모 Undo/Redo를 공통 편집 로직에 +연결합니다. 임시 자식 편집기나 자식 History를 생성하지 않습니다. 선택은 저장하지 않고 +문서 변경은 검증 후 write 한 번으로 전달합니다. 실패한 write는 다음 선택을 적용하지 +않습니다. write 구현은 부모 문서에 원자적으로 반영하고 실패 시 원본을 유지해야 합니다. +read는 부모 snapshot.value의 변경에 맞춰 갱신됩니다. + +`availability`는 ready/missing/invalid/readonly입니다. read의 null은 누락을 뜻하고, +나머지 값은 Sheet 정본 스키마로 검증합니다. readOnly 정책은 변경을 막되 선택·복사는 +허용합니다. SheetHand는 상태를 표시하고 편집 진입을 막으며 열린 draft를 취소합니다. + +Markdown 연결은 표와 원문 사이의 변환, Object 연결은 내장 Sheet payload 접근을 소유합니다. +Canvas가 바깥 객체 위치·크기와 활성화 경계를 소유하고 Sheet가 내부 셀과 행·열 편집을 +소유합니다. 입력 프로파일은 Affordance, 표현과 편집 필드는 Hand의 계약으로 교체합니다. + +Usage: [표 편집](/demo/sheet), [Sheet 앱](/applications/sheet), [Canvas](/demo/canvas). + +## View의 문서 연결 + +`SheetEditor.createView(options)`는 원본 편집기에 연결된 SheetEditor를 반환합니다. +원본은 문서와 History의 단일 소유자이고 View는 선택과 표시 순서를 소유합니다. +View에서 변경하면 구독 중인 다른 View에 동기적으로 반영됩니다. 선택 이동은 원본 +문서나 다른 View의 선택을 바꾸지 않습니다. 같은 셀을 다른 View에서 수정한 경우 +History 그룹을 분리하여 각각 되돌립니다. + +`rowOrder`/`columnOrder`는 표시 우선순위인 고유 ID 배열입니다. 중복·빈 ID는 거절하고, +문서에서 사라진 ID는 제외하며 목록에 없는 새 ID는 문서 순서로 뒤에 붙입니다. +필터링이 아니므로 모든 현재 행·열을 포함합니다. `grid`와 `projectSheetGrid`는 문서 +값을 복제하지 않고 표시 순서와 topology를 제공합니다. 초기 순서 옵션은 불변 값으로 +사용합니다. 구조 삽입의 index는 문서 index이며 View 정렬 자체를 변경하지 않습니다. + +`readOnly`는 변경 실행 경계에서 적용합니다. 자식 View가 부모의 읽기 전용 상태를 +해제할 수 없고 Undo/Redo도 차단합니다. 선택·복사는 계속 가능합니다. + +Usage: [하나의 표, 두 View](/demo/sheet-views). diff --git a/packages/json-document-editing/docs/sheet.md b/packages/json-document-editing/docs/sheet.md index 301b42c9..ddf9c6fe 100644 --- a/packages/json-document-editing/docs/sheet.md +++ b/packages/json-document-editing/docs/sheet.md @@ -31,3 +31,11 @@ editor.dispatch(editor.structure.insertRow); ## 기본 Sheet 애플리케이션 `sheet.rename` Intent는 문서의 `name`을 History에 포함해 변경합니다. `structure.minimumRows`와 `minimumColumns`로 빈 축으로 인한 편집 불능을 방지합니다. `sheetSelectionSummary(editor)`는 활성 셀의 A1 좌표 및 선택/입력 셀 수를 제공합니다. `/applications/sheet`는 이 API와 SheetHand 및 Web 로컬 저장을 조합합니다. + +## 내장 표와 부모 History + +`createProjectedSheetEditor({source, read, write, sheet, mapSelection})`는 부모 source에서 표 값을 읽고, 표 명령의 결과만 부모에 기록합니다. 선택 이동은 부모를 쓰지 않습니다. Undo/Redo는 부모에게 위임합니다. `mapSelection`은 Markdown처럼 직렬화 후 위치 기반 ID가 바뀌는 형식에서 사용합니다. + +`createObjectSheetEditor(parent, objectId)`는 Object의 `embedded-document` / `sheet/1` payload에 연결합니다. `createCanvasSheet(bounds, {rows?, columns?})`는 기본 4행·3열의 표 객체 초안을 생성합니다. `object.document` Intent는 내장 문서 값만 변경하므로 바깥 객체의 위치와 선택을 보존합니다. Markdown과 Canvas 모두 같은 projection lifecycle을 소비하며 개별 어댑터에는 읽기·쓰기·ID 변환만 남습니다. + +`parseSheetClipboardText(text)`는 외부 탭 구분 표를 SheetClipboard로 읽습니다. CRLF, 인용부호로 감싼 탭/줄바꿈과 이중 인용부호를 처리하고 짧은 행은 빈 셀로 보충합니다. 닫히지 않은 인용부호는 거절합니다. diff --git a/packages/json-document-editing/package.json b/packages/json-document-editing/package.json index c14ee3ba..982fec1d 100644 --- a/packages/json-document-editing/package.json +++ b/packages/json-document-editing/package.json @@ -17,7 +17,13 @@ "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "docs", + "LICENSE" + ], "exports": { ".": { "types": "./dist/index.d.ts", @@ -38,7 +44,8 @@ "@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-markdown": "^0.1.0-rc.0" + "@interactive-os/json-document-markdown": "^0.1.0-rc.0", + "@interactive-os/json-document-sheet-document": "^0.1.0-rc.0" }, "devDependencies": { "@interactive-os/json-document-object-document": "*", @@ -48,6 +55,7 @@ "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7", - "@interactive-os/json-document-markdown": "*" + "@interactive-os/json-document-markdown": "*", + "@interactive-os/json-document-sheet-document": "*" } } diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index af16da14..2cd282a0 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -237,3 +237,13 @@ export { createMarkdownTableEditor } from "./markdown-table.js"; export { sheetNavigationTarget } from "./sheet-navigation.js"; export type { SheetTraversalDirection } from "./sheet-navigation.js"; export {sheetSelectionSummary} from "./sheet-summary.js"; +export {parseSheetClipboardText} from "./sheet-text-clipboard.js"; +export {createProjectedSheetEditor, type ProjectedSheetOptions, type ProjectedSheetSource} from "./projected-sheet.js"; +export {createCanvasSheet, createObjectSheetEditor, sheetEmbeddedDocumentType} from "./object-sheet.js"; + +export {planSheetIntent} from "./sheet-plan.js"; +export type {SheetPlanResult} from "./sheet-plan.js"; +export type {SheetAvailability} from "./sheet.js"; + +export {projectSheetGrid} from "./sheet-view.js"; +export type {SheetGrid,SheetViewOptions} from "./sheet-view.js"; diff --git a/packages/json-document-editing/src/markdown-table.ts b/packages/json-document-editing/src/markdown-table.ts index 66d4d67a..0d384905 100644 --- a/packages/json-document-editing/src/markdown-table.ts +++ b/packages/json-document-editing/src/markdown-table.ts @@ -1,76 +1,28 @@ -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"; +import type {SheetEditor,SheetPoint} from "./sheet.js"; +import {jsonCellText} from "./cell-text.js"; +import {sheetColumnLabel} from "./sheet-structure.js"; +import type {TextEditor} from "./text.js"; +import {readMarkdownTable,replaceMarkdownTable} from "@interactive-os/json-document-markdown"; +import {createProjectedSheetEditor} from "./projected-sheet.js"; -/** 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: 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] ?? ""]))}))}, {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));}; - const mutate = (intent: SheetIntent): EditingResult => { - read(); - const table = readMarkdownTable(text.text, position()); - 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" || 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 => { - 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 capabilities() {read(); return sheet.capabilities;}, - get structure() {read(); return sheet.structure;}, - 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}; +/** Markdown serialization belongs to Markdown; projection/history lifecycle belongs to Editing. */ +export function createMarkdownTableEditor(text:TextEditor,position:()=>number):SheetEditor { + return createProjectedSheetEditor({source:text,sheet:{resize:false,structure:{headerRows:1,minimumColumns:1}}, + read(){ + const table=readMarkdownTable(text.text,position());if(!table)return null; + const width=table.align.length || table.rows[0]?.length || 0; + const columns=Array.from({length:width},(_,i)=>({id:`c${i}`,label:sheetColumnLabel(i)})); + return {columns,rows:table.rows.map((row,i)=>({id:`r${i}`,cells:Object.fromEntries(columns.map((column,j)=>[column.id,row[j] ?? ""]))}))}; }, - 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;}}; + write(value){ + const table=readMarkdownTable(text.text,position());if(!table)return {ok:false,code:"table.unavailable"}; + 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); + return text.replace(replaceMarkdownTable(text.text,table,rows,align),{anchor:table.from,focus:table.from}); }, - }; + mapSelection(selection,value){ + 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)}`}); + return {...selection,anchor:selection.anchor && point(selection.anchor),focus:selection.focus && point(selection.focus),ranges:selection.ranges.map(range=>({anchor:point(range.anchor),focus:point(range.focus)}))}; + }, + }); } diff --git a/packages/json-document-editing/src/object-sheet.ts b/packages/json-document-editing/src/object-sheet.ts new file mode 100644 index 00000000..fb19cbf0 --- /dev/null +++ b/packages/json-document-editing/src/object-sheet.ts @@ -0,0 +1,25 @@ +import {createCanvasEmbeddedDocument,type CanvasObjectDraft,type ObjectBounds} from "@interactive-os/json-document-object-document"; +import {createProjectedSheetEditor} from "./projected-sheet.js"; +import {createSheetDocument} from "@interactive-os/json-document-sheet-document"; +import type {ObjectEditor} from "./object.js"; +import type {SheetEditor} from "./sheet.js"; + +export const sheetEmbeddedDocumentType="sheet/1"; +/** Create a table document within the Object owner's spatial container. */ +export function createCanvasSheet(bounds:ObjectBounds,options:{rows?:number;columns?:number}={}):CanvasObjectDraft { + const rowCount=options.rows ?? 4,columnCount=options.columns ?? 3; + if(!Number.isSafeInteger(rowCount)||!Number.isSafeInteger(columnCount)||rowCount<1||columnCount<1)throw new RangeError("Sheet dimensions must be positive integers."); + const document=createSheetDocument({rows:rowCount,columns:columnCount,columnWidth:120}); + return createCanvasEmbeddedDocument(sheetEmbeddedDocumentType,document,bounds,"표"); +} +/** The parent remains the only persisted history owner, including edits from the table toolbar. */ +export function createObjectSheetEditor(parent:ObjectEditor,objectId:string):SheetEditor { + return createProjectedSheetEditor({source:parent,sheet:{structure:{minimumRows:1,minimumColumns:1}}, + read(){ + const object=(parent.snapshot.value as {objects:ReadonlyArray>}).objects.find(object=>object.id === objectId); + if(object?.kind !== "embedded-document" || object.documentType !== sheetEmbeddedDocumentType)return null; + return object.document; + }, + write(document){return parent.dispatch({type:"object.document",objectId,document});}, + }); +} diff --git a/packages/json-document-editing/src/object.ts b/packages/json-document-editing/src/object.ts index 21086846..1266b54b 100644 --- a/packages/json-document-editing/src/object.ts +++ b/packages/json-document-editing/src/object.ts @@ -60,6 +60,7 @@ export type ObjectIntent = | { readonly type: "object.create"; readonly object: ObjectDraft } | { readonly type: "object.duplicate"; readonly objectIds: ReadonlyArray; readonly placement?: ObjectPastePlacement } | { readonly type: "object.remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "object.document"; readonly objectId: string; readonly document: JSONValue } | { readonly type: "object.text"; readonly objectId: string; readonly text: string } | { readonly type: "document.replace"; readonly document: ObjectDocument } | { @@ -148,6 +149,7 @@ export function createObjectEditor( const object = { ...intent.object, id }; return apply({ type: "insert", objects: [object] }, selectionFor([object.id]), intent.type); } + if (intent.type === "object.document") return apply({type:"embedded-document",objectId:intent.objectId,document:intent.document},session.snapshot.selection,intent.type); if (intent.type === "object.text") { return apply({ type: "text", objectId: intent.objectId, text: intent.text }, selectionForTargets([intent.objectId]), intent.type); } diff --git a/packages/json-document-editing/src/projected-sheet.ts b/packages/json-document-editing/src/projected-sheet.ts new file mode 100644 index 00000000..b6b9e252 --- /dev/null +++ b/packages/json-document-editing/src/projected-sheet.ts @@ -0,0 +1,79 @@ +import {dispatchSheetIntent} from "./sheet-plan.js"; +import {applyPatch,jsonEqual,type JSONValue} from "@interactive-os/json-document"; +import {assertSheetDocument} from "@interactive-os/json-document-sheet-document"; +import {bindSheetEditing,reconcileSheetSelection,type SheetDocument,type SheetEditor,type SheetEditorOptions,type SheetSelection,type SheetAvailability} from "./sheet.js"; +import type {EditingSession,EditingSnapshot} from "./session.js"; + +export interface ProjectedSheetSource { + readonly snapshot: {readonly value: JSONValue;readonly canUndo: boolean;readonly canRedo: boolean}; + subscribe(listener: () => void): () => void; + undo(): {readonly ok:boolean;readonly code?:string}; + redo(): {readonly ok:boolean;readonly code?:string}; +} +export type {SheetAvailability} from "./sheet.js"; +export interface ProjectedSheetOptions { + readonly source: ProjectedSheetSource; + /** Null means absent; a present value is validated by the canonical Sheet schema. */ + readonly read: () => unknown; + readonly write: (value: SheetDocument) => {readonly ok:boolean;readonly code?:string}; + readonly readOnly?:()=>boolean; + readonly sheet?: SheetEditorOptions; + /** Formats with position-based IDs remap selection after structural edits. */ + readonly mapSelection?: (selection:SheetSelection,value:SheetDocument) => SheetSelection; +} + +/** Bind the common Sheet commands directly to the parent's transaction and history. */ +export function createProjectedSheetEditor(options:ProjectedSheetOptions):SheetEditor { + const empty:SheetDocument={rows:[],columns:[]}; + let observed:JSONValue|undefined,document=empty,availability:SheetAvailability="missing",revision=0; + let selection=reconcileSheetSelection(empty,options.sheet?.selection),initialized=false,committing=false; + const listeners=new Set<(snapshot:EditingSnapshot)=>void>(); + let unsubscribe:(()=>void)|undefined; + const read=()=>{ + const value=options.source.snapshot.value; + if(observed !== value){ + observed=value; + const candidate=options.read(); + if(candidate === null || candidate === undefined){availability="missing";document=empty;} + else {try{assertSheetDocument(candidate);document=candidate;availability="ready";}catch{availability="invalid";document=empty;}} + selection=reconcileSheetSelection(document,initialized ? selection:options.sheet?.selection); + initialized=true; + } + if(availability === "ready" || availability === "readonly")availability=options.readOnly?.() ? "readonly":"ready"; + }; + const snapshot=():EditingSnapshot=>{read();return {value:document,selection,revision,canUndo:availability === "ready" && options.source.snapshot.canUndo,canRedo:availability === "ready" && options.source.snapshot.canRedo};}; + const publish=()=>{revision++;const next=snapshot();for(const listener of listeners)listener(next);}; + const history=(action:"undo"|"redo")=>{ + read();if(availability !== "ready")return {ok:false as const,code:`table.${availability}`}; + committing=true;let result:ReturnType; + try{result=options.source[action]();}finally{committing=false;} + publish();return result.ok ? {ok:true as const,snapshot:snapshot()}:{ok:false as const,code:result.code ?? "history.unavailable"}; + }; + const session:EditingSession={ + get snapshot(){return snapshot();}, + apply(plan){ + read();if(availability !== "ready")return {ok:false,code:`table.${availability}`}; + const next=applyPatch(document,plan.operations); + if(!next.ok)return {ok:false,code:"sheet.invalid-plan"}; + try{assertSheetDocument(next.value);}catch{return {ok:false,code:"sheet.invalid-document"};} + if(jsonEqual(document,next.value)){selection=plan.selectionAfter;publish();return {ok:true,snapshot:snapshot()};} + const pending=options.mapSelection?.(plan.selectionAfter,next.value) ?? plan.selectionAfter; + committing=true;let result:ReturnType; + try{result=options.write(next.value);}finally{committing=false;} + // A failed write leaves selection and the visible document at the parent's state. + observed=undefined;read(); + if(result.ok)selection=reconcileSheetSelection(document,pending); + publish();return result.ok ? {ok:true,snapshot:snapshot()}:{ok:false,code:result.code ?? "table.commit-failed"}; + }, + select(next){selection=next;publish();return snapshot();}, + reconcile(fn){read();selection=fn(selection,document);publish();return snapshot();}, + undo:()=>history("undo"),redo:()=>history("redo"), + subscribe(listener){listeners.add(listener);unsubscribe ??= options.source.subscribe(()=>{if(!committing)publish();});return ()=>{listeners.delete(listener);if(!listeners.size){unsubscribe?.();unsubscribe=undefined;}};}, + }; + const editor=bindSheetEditing(session,options.sheet); + return {...editor, + get availability(){read();return availability;},get grid(){return editor.grid;}, + get capabilities(){return editor.capabilities;},get structure(){return editor.structure;},get snapshot(){return snapshot();},get selectedCells(){return editor.selectedCells;}, + dispatch(intent){read();if(availability === "missing" || availability === "invalid")return {ok:false,code:`table.${availability}`};return dispatchSheetIntent(session,intent,options.sheet);}, + }; +} diff --git a/packages/json-document-editing/src/sheet-plan.ts b/packages/json-document-editing/src/sheet-plan.ts new file mode 100644 index 00000000..3b656e89 --- /dev/null +++ b/packages/json-document-editing/src/sheet-plan.ts @@ -0,0 +1,28 @@ +import type {EditingPlan,EditingSession,EditingSnapshot} from "./session.js"; +import {bindSheetEditing,reconcileSheetSelection,type SheetDocument,type SheetSelection,type SheetIntent,type SheetEditorOptions} from "./sheet.js"; +import {assertSheetDocument} from "@interactive-os/json-document-sheet-document"; + +export type SheetPlanResult = {readonly ok:true;readonly plan:EditingPlan} | {readonly ok:false;readonly code:string}; +/** Compute a command without creating a document, recording history or notifying observers. */ +export function planSheetIntent(document:SheetDocument,selection:SheetSelection,intent:SheetIntent,options:SheetEditorOptions={}):SheetPlanResult { + assertSheetDocument(document); + let snapshot:EditingSnapshot={value:document,selection:reconcileSheetSelection(document,selection),revision:0,canUndo:false,canRedo:false}; + let plan:EditingPlan={operations:[],selectionAfter:snapshot.selection,origin:intent.type}; + const unavailable=()=>({ok:false as const,code:"history.unavailable"}); + const session:EditingSession={ + get snapshot(){return snapshot;}, + apply(next){plan=next;return {ok:true,snapshot};}, + select(next){plan={operations:[],selectionAfter:next,origin:intent.type};snapshot={...snapshot,selection:next};return snapshot;}, + reconcile(fn){return this.select(fn(snapshot.selection,document));}, + undo:unavailable,redo:unavailable,subscribe:()=>()=>{}, + }; + const result=bindSheetEditing(session,options).dispatch(intent); + return result.ok ? {ok:true,plan}:result; +} + +/** Apply the common pure plan through a standalone or parent-owned session. */ +export function dispatchSheetIntent(session:EditingSession,intent:SheetIntent,options:SheetEditorOptions={}) { + const result=planSheetIntent(session.snapshot.value as SheetDocument,session.snapshot.selection,intent,options); + if(!result.ok)return result; + return result.plan.operations.length ? session.apply(result.plan):{ok:true as const,snapshot:session.select(result.plan.selectionAfter)}; +} diff --git a/packages/json-document-editing/src/sheet-structure.ts b/packages/json-document-editing/src/sheet-structure.ts index eed10842..e37bdea1 100644 --- a/packages/json-document-editing/src/sheet-structure.ts +++ b/packages/json-document-editing/src/sheet-structure.ts @@ -1,3 +1,4 @@ +export {createSheetRow,createSheetColumn,sheetColumnLabel} from "@interactive-os/json-document-sheet-document"; import type { SheetColumn, SheetDocument, SheetRow, SheetSelection } from "./sheet.js"; export type SheetStructureIntent = @@ -18,14 +19,6 @@ export interface SheetStructureActions { 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"; @@ -48,16 +41,3 @@ export function sheetStructureActions(document: SheetDocument, selection: SheetS 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-summary.ts b/packages/json-document-editing/src/sheet-summary.ts index a893e1b6..4c4739b9 100644 --- a/packages/json-document-editing/src/sheet-summary.ts +++ b/packages/json-document-editing/src/sheet-summary.ts @@ -4,7 +4,7 @@ import type {SheetEditor, SheetDocument} from "./sheet.js"; /** Coordinates and value counts derived from the canonical selection. */ export function sheetSelectionSummary(editor: SheetEditor): {address: string; selected: number; filled: number} { - const sheet = editor.snapshot.value as SheetDocument; + const sheet = editor.grid; const focus = editor.snapshot.selection.focus; const row = sheet.rows.findIndex(item => item.id === focus?.rowId); const column = sheet.columns.findIndex(item => item.id === focus?.columnId); diff --git a/packages/json-document-editing/src/sheet-text-clipboard.ts b/packages/json-document-editing/src/sheet-text-clipboard.ts new file mode 100644 index 00000000..9835502f --- /dev/null +++ b/packages/json-document-editing/src/sheet-text-clipboard.ts @@ -0,0 +1,22 @@ +import {sheetClipboardFormat, type SheetClipboard} from "./sheet.js"; + +/** Read tab-separated spreadsheet text, including quoted tabs/newlines and doubled quotes. */ +export function parseSheetClipboardText(text: string): SheetClipboard | null { + if (!text) return null; + const rows: string[][]=[]; let row: string[]=[],field="",quoted=false; + for(let i=0;irow.length)); + return {type:sheetClipboardFormat.mimeType,text,cells:rows.map(row=>Array.from({length:width},(_,i)=>row[i] ?? ""))}; +} diff --git a/packages/json-document-editing/src/sheet-validation.ts b/packages/json-document-editing/src/sheet-validation.ts index b10b0bda..52533d81 100644 --- a/packages/json-document-editing/src/sheet-validation.ts +++ b/packages/json-document-editing/src/sheet-validation.ts @@ -1,19 +1,4 @@ -import type { SheetDocument } from "./sheet.js"; - -export function assertSheetDocument(document: SheetDocument): void { - if (!document || !Array.isArray(document.columns) || !Array.isArray(document.rows)) throw new Error("Sheet requires row and column arrays."); - for (const column of document.columns) { - if (!column || typeof column.id !== "string" || typeof column.label !== "string") throw new Error("Sheet columns require string ids and labels."); - } - for (const row of document.rows) { - if (!row || typeof row.id !== "string" || !row.cells || typeof row.cells !== "object" || Array.isArray(row.cells)) throw new Error("Sheet rows require string ids and cell records."); - } - assertUniqueSheetIds(document.columns.map((column) => column.id), "column"); - assertUniqueSheetIds(document.rows.map((row) => row.id), "row"); - for (const row of document.rows) for (const column of document.columns) { - if (!Object.prototype.hasOwnProperty.call(row.cells, column.id)) throw new Error(`Sheet row ${JSON.stringify(row.id)} is missing column ${JSON.stringify(column.id)}.`); - } -} +export {assertSheetDocument} from "@interactive-os/json-document-sheet-document"; export function assertUniqueSheetIds(ids: ReadonlyArray, label: "row" | "column"): void { const unique = new Set(); diff --git a/packages/json-document-editing/src/sheet-view.ts b/packages/json-document-editing/src/sheet-view.ts new file mode 100644 index 00000000..83a10dda --- /dev/null +++ b/packages/json-document-editing/src/sheet-view.ts @@ -0,0 +1,74 @@ +import type {EditingPlan,EditingResult,EditingSession,EditingSnapshot} from "./session.js"; +import {bindSheetEditing,reconcileSheetSelection,type SheetDocument,type SheetEditor,type SheetEditorOptions,type SheetSelection} from "./sheet.js"; +import {dispatchSheetIntent} from "./sheet-plan.js"; +import type {SheetColumn,SheetRow} from "@interactive-os/json-document-sheet-document"; +import type {GridTopology} from "./topology.js"; + +/** View order is local presentation state, never a document reorder. New IDs append in document order. */ +export interface SheetViewOptions { + readonly rowOrder?:ReadonlyArray; + readonly columnOrder?:ReadonlyArray; + readonly readOnly?:()=>boolean; + readonly selection?:SheetSelection; +} +export interface SheetGrid extends GridTopology { + readonly rows:ReadonlyArray; + readonly columns:ReadonlyArray; +} +/** Resolve stable IDs to the current document, dropping deleted IDs and including new ones. */ +export function projectSheetGrid(document:SheetDocument,order:SheetViewOptions={}):SheetGrid { + function ordered(items:ReadonlyArray,preferred?:ReadonlyArray):ReadonlyArray { + if(!preferred)return items; + if(new Set(preferred).size !== preferred.length || preferred.some(id=>!id))throw new Error("Sheet View order requires unique nonempty IDs."); + const remaining=new Map(items.map(item=>[item.id,item])); + const result:T[]=[]; + for(const id of preferred){const item=remaining.get(id);if(item){result.push(item);remaining.delete(id);}} + return [...result,...remaining.values()]; + } + const rows=ordered(document.rows,order.rowOrder),columns=ordered(document.columns,order.columnOrder); + return {rows,columns,rowIds:rows.map(row=>row.id),columnIds:columns.map(column=>column.id)}; +} + +let nextViewId=0; + +/** Internal bridge: a View owns selection; its source alone owns data and History. */ +export function bindSheetView(source:SheetEditor,commit:(plan:EditingPlan)=>EditingResult,options:SheetEditorOptions):SheetEditor { + const historyScope=`sheet-view-${++nextViewId}`; + const first=projectSheetGrid(source.snapshot.value as SheetDocument,options); + let selection=reconcileSheetSelection({...source.snapshot.value as SheetDocument,rows:first.rows,columns:first.columns},options.selection); + let observed=source.snapshot.value,observedRevision=source.snapshot.revision,revision=0,committing=false; + const listeners=new Set<(snapshot:EditingSnapshot)=>void>(); + let unsubscribe:(()=>void)|undefined; + const availability=()=>source.availability === "ready" && options.readOnly?.() ? "readonly":source.availability; + const read=()=>{ + const next=source.snapshot; + if(next.value !== observed || next.revision !== observedRevision)revision++; + if(next.value !== observed){observed=next.value;selection=reconcileSheetSelection(next.value as SheetDocument,selection);} + observedRevision=next.revision; + }; + const snapshot=():EditingSnapshot=>{read();return {...source.snapshot,selection,revision,canUndo:availability() === "ready" && source.snapshot.canUndo,canRedo:availability() === "ready" && source.snapshot.canRedo};}; + const publish=()=>{revision++;const next=snapshot();for(const listener of listeners)listener(next);}; + const history=(action:"undo"|"redo"):EditingResult=>{ + if(source.availability !== "ready" || options.readOnly?.())return {ok:false,code:"table.readonly"}; + const result=source[action]();return result.ok ? {ok:true,snapshot:snapshot()}:result; + }; + const session:EditingSession={ + get snapshot(){return snapshot();}, + apply(plan){ + if(source.availability !== "ready")return {ok:false,code:`table.${source.availability}`}; + committing=true;let result:EditingResult; + try{result=commit(plan.historyGroup ? {...plan,historyGroup:`${historyScope}:${plan.historyGroup}`}:plan);}finally{committing=false;} + if(result.ok){read();selection=reconcileSheetSelection(source.snapshot.value as SheetDocument,plan.selectionAfter);publish();return {ok:true,snapshot:snapshot()};} + return result; + }, + select(next){selection=next;publish();return snapshot();}, + reconcile(fn){selection=fn(selection,source.snapshot.value);publish();return snapshot();}, + undo:()=>history("undo"),redo:()=>history("redo"), + subscribe(listener){listeners.add(listener);unsubscribe ??= source.subscribe(()=>{if(!committing)publish();});return ()=>{listeners.delete(listener);if(!listeners.size){unsubscribe?.();unsubscribe=undefined;}};}, + }; + const view=bindSheetEditing(session,options); + return {...view, + get availability(){return availability();},get grid(){return view.grid;},get structure(){return view.structure;},get capabilities(){return view.capabilities;},get selectedCells(){return view.selectedCells;},get snapshot(){return snapshot();}, + dispatch(intent){if(availability() !== "ready" && !intent.type.startsWith("selection."))return {ok:false,code:`table.${availability()}`};return dispatchSheetIntent(session,intent,options);}, + }; +} diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index 216e6dc6..0a236068 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -1,3 +1,7 @@ +import {bindSheetView,projectSheetGrid,type SheetGrid,type SheetViewOptions} from "./sheet-view.js"; +import {dispatchSheetIntent} from "./sheet-plan.js"; +import type {SheetDocument,SheetColumn,SheetRow} from "@interactive-os/json-document-sheet-document"; +export type {SheetDocument,SheetColumn,SheetRow} from "@interactive-os/json-document-sheet-document"; import { createSheetRow, createSheetColumn, sheetStructureActions, sheetStructureViolation, type SheetStructureIntent, type SheetStructurePolicy, type SheetStructureActions } from "./sheet-structure.js"; import { buildPointer, @@ -28,21 +32,6 @@ import { import { jsonCellText } from "./cell-text.js"; import { sheetNavigationTarget, type SheetTraversalDirection } from "./sheet-navigation.js"; -export interface SheetColumn extends Record { - readonly id: string; - readonly label: string; -} - -export interface SheetRow extends Record { - readonly id: string; - readonly cells: Readonly>; -} - -export interface SheetDocument extends Record { - readonly columns: ReadonlyArray; - readonly rows: ReadonlyArray; -} - export interface SheetPoint extends Record { readonly rowId: string; readonly columnId: string; @@ -93,7 +82,7 @@ export type SheetIntent = | { 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: "range.fill"; readonly source: SheetRange; readonly target: SheetRange; readonly topology?:SheetTopology } | { readonly type: "selection.navigate"; readonly direction: SheetTraversalDirection; readonly topology?: SheetTopology } | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { @@ -120,7 +109,12 @@ export type SheetIntent = readonly topology?: SheetTopology; }; +export type SheetAvailability = "ready" | "missing" | "invalid" | "readonly"; + export interface SheetEditor { + readonly availability: SheetAvailability; + readonly grid: SheetGrid; + createView(options?:SheetViewOptions):SheetEditor; readonly capabilities: {readonly resize: boolean}; readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; @@ -134,7 +128,7 @@ export interface SheetEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export interface SheetEditorOptions extends EditingHistoryOptions { +export interface SheetEditorOptions extends EditingHistoryOptions, SheetViewOptions { /** False for formats such as GFM that cannot persist row heights or column widths. */ readonly resize?: boolean; readonly structure?: SheetStructurePolicy; @@ -146,11 +140,8 @@ export function createSheetEditor(source: EditingDocumentSource, const document = resolveDocumentSource(source); const initial = document.value as SheetDocument; assertSheetDocument(initial); - 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), 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 grid=projectSheetGrid(initial,options); + const initialSelection = reconcileSheetSelection({...initial,rows:grid.rows,columns:grid.columns}, options.selection); const session = createEditingSession({ ...options, document, @@ -162,6 +153,22 @@ export function createSheetEditor(source: EditingDocumentSource, }), 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), }); + const editor=bindSheetEditing(session, options); + editor.dispatch=intent=>dispatchSheetIntent(session,intent,options); + return editor; +} + +/** Shared selection reconciliation for standalone and parent-owned documents. */ +export function reconcileSheetSelection(initial:SheetDocument, selection?:SheetSelection):SheetSelection { + const firstRow=initial.rows[0],firstColumn=initial.columns[0]; + if(!selection)return firstRow && firstColumn ? collapsed(firstRow.id,firstColumn.id):emptySelection(); + const exists=(point:SheetPoint)=>initial.rows.some(row=>row.id === point.rowId) && initial.columns.some(column=>column.id === point.columnId); + return withPrimaryAliases(reconcileRangeSelection(selection,point=>exists(point)?point:null),selection.focus && exists(selection.focus)?selection.focus:null); +} + +/** Internal binding: one command implementation, independent of document/history storage. */ +export function bindSheetEditing(session:EditingSession,options:SheetEditorOptions={}):SheetEditor { + const initial=session.snapshot.value as SheetDocument; let indexedDocument: SheetDocument | undefined = initial; let indexedSheet: SheetIndex | undefined = createSheetIndex(initial); @@ -173,6 +180,10 @@ export function createSheetEditor(source: EditingDocumentSource, return indexedSheet as SheetIndex; } + function resolveTopology(document:SheetDocument,topology?:SheetTopology,sheetIndex=index(document)):SheetTopology { + return resolveExplicitTopology(document,topology ?? projectSheetGrid(document,options),sheetIndex); + } + function value(): SheetDocument { return session.snapshot.value as SheetDocument; } @@ -221,9 +232,10 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + if (options.readOnly?.() && (!intent.type.startsWith("selection.") || intent.type === "selection.fill")) return failure("table.readonly"); if (intent.type === "sheet.rename") return session.apply({operations:[{op:"add",path:"/name",value:intent.name}],selectionAfter:session.snapshot.selection,origin:intent.type,historyGroup:"sheet.name"}); 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); + const current=projectSheetGrid(value(),options), 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}} @@ -231,7 +243,7 @@ export function createSheetEditor(source: EditingDocumentSource, } if (intent.type === "range.fill") { const current = value(); - const topology = resolveTopology(current, undefined, index()); + const topology = resolveTopology(current, intent.topology, 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"); @@ -241,7 +253,9 @@ export function createSheetEditor(source: EditingDocumentSource, 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]!}); + const destination=resolvePointWithIndices(current,topology.rowIds[r]!,topology.columnIds[c]!,index())!; + const sourceRow=index().rowById.get(topology.rowIds[row]!)!; + operations.push({op:"replace",path:buildPointer(["rows",destination.rowIndex,"cells",topology.columnIds[c]!]),value:sourceRow.cells[topology.columnIds[column]!]!}); } return session.apply({operations,selectionAfter:withPrimaryAliases(replaceRangeSelection(session.snapshot.selection,intent.target,sameSheetPoint)),origin:"range.fill"}); } @@ -287,6 +301,7 @@ export function createSheetEditor(source: EditingDocumentSource, columns = columns.filter(column => column.id !== intent.columnId); rows = rows.map(row => { const cells = {...row.cells}; delete cells[intent.columnId]; return {...row, cells}; }); } + try {assertSheetDocument({...current,rows,columns});} catch {return failure("sheet.invalid-document");} 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); @@ -338,7 +353,7 @@ export function createSheetEditor(source: EditingDocumentSource, }); } - return paste(session, value(), intent.clipboard, intent.topology, index()); + return paste(session, value(), intent.clipboard, resolveTopology(value(),intent.topology), index()); } function copy(topology?: SheetTopology): SheetClipboard | null { @@ -362,6 +377,7 @@ export function createSheetEditor(source: EditingDocumentSource, function cut(topology?: SheetTopology): { readonly clipboard: SheetClipboard; readonly result: EditingResult } | null { return cutEditingClipboard(() => copy(topology), () => { + if (options.readOnly?.()) return failure("table.readonly"); const document = value(); const axes = resolveTopology(document, topology, index(document)); const range = primaryRange(session.snapshot.selection); @@ -383,16 +399,25 @@ export function createSheetEditor(source: EditingDocumentSource, } return { + get availability() {return options.readOnly?.() ? "readonly" as const:"ready" as const;}, + get grid(){return projectSheetGrid(value(),options);}, + createView(viewOptions={}) { + const parent=this; + return bindSheetView(parent,plan=>{ + if(parent.availability !== "ready")return failure(`table.${parent.availability}`); + return session.apply({...plan,selectionAfter:session.snapshot.selection}); + },{...options,...viewOptions,readOnly:()=>parent.availability !== "ready" || !!viewOptions.readOnly?.()}); + }, get capabilities() {return {resize: options.resize !== false};}, get structure() { return sheetStructureActions(value(), session.snapshot.selection, options.structure ?? {}); }, - get snapshot() { return session.snapshot; }, + get snapshot() {const snapshot=session.snapshot;return options.readOnly?.() ? {...snapshot,canUndo:false,canRedo:false}:snapshot;}, get selectedCells() { return selectedCells(); }, selectedCellsIn: (topology) => selectedCells(topology), dispatch, copy, cut, - undo: () => session.undo(), - redo: () => session.redo(), + undo: () => options.readOnly?.() ? failure("table.readonly"):session.undo(), + redo: () => options.readOnly?.() ? failure("table.readonly"):session.redo(), subscribe: (listener) => session.subscribe(listener), }; } @@ -414,7 +439,7 @@ function paste( if (clipboard.cells.some((row) => row.length !== width)) { return failure("clipboard.not-rectangular"); } - const axes = resolveTopology(document, topology, index); + const axes = resolveExplicitTopology(document, topology, index); const start = resolvePointInTopology(axes, focus.rowId, focus.columnId); if (start === null) return failure("selection.cell-not-found"); if (start.rowIndex + clipboard.cells.length > axes.rowIds.length || start.columnIndex + width > axes.columnIds.length) { @@ -480,7 +505,7 @@ function createSheetIndex(document: SheetDocument): SheetIndex { }; } -function resolveTopology(document: SheetDocument, topology?: SheetTopology, index = createSheetIndex(document)): SheetTopology { +function resolveExplicitTopology(document: SheetDocument, topology?: SheetTopology, index = createSheetIndex(document)): SheetTopology { const resolved = topology ?? index.defaultTopology; if (index.validatedTopologies.has(resolved)) return resolved; assertTopologyAxis(resolved.rowIds, index.rowById, "row"); diff --git a/packages/json-document-editing/tests/object-sheet.test.ts b/packages/json-document-editing/tests/object-sheet.test.ts new file mode 100644 index 00000000..f65ab01b --- /dev/null +++ b/packages/json-document-editing/tests/object-sheet.test.ts @@ -0,0 +1,20 @@ +import {expect,test,vi} from "vitest"; +import {createJSONDocument} from "@interactive-os/json-document"; +import {parseCanvasDocument,serializeCanvasDocument,type CanvasDocument} from "@interactive-os/json-document-object-document"; +import {createCanvasSheet,createObjectEditor,createObjectSheetEditor,type SheetDocument} from "../src/index.js"; + +test("embedded sheet writes one parent transaction and shares parent Undo, duplication and serialization",()=>{ + const initial:CanvasDocument={profile:"canvas/1",width:1280,height:720,objects:[{...createCanvasSheet({x:100,y:100,width:480,height:280}),id:"table"}]}; + const document=createJSONDocument(initial),changed=vi.fn();document.subscribe(changed); + const parent=createObjectEditor(document),sheet=createObjectSheetEditor(parent,"table"); + const release=sheet.subscribe(()=>{}); + sheet.dispatch({type:"selection.set",rowId:"row-2",columnId:"column-2",mode:"extend"});expect(changed).not.toHaveBeenCalled(); + expect(sheet.dispatch({type:"cell.commit",rowId:"row-1",columnId:"column-1",value:"abc",preserveSelection:true}).ok).toBe(true); + expect(changed).toHaveBeenCalledTimes(1);expect((sheet.snapshot.value as SheetDocument).rows[0]!.cells["column-1"]).toBe("abc"); + sheet.undo();expect(parent.snapshot.value).toEqual(initial);parent.redo();expect((sheet.snapshot.value as SheetDocument).rows[0]!.cells["column-1"]).toBe("abc"); + parent.dispatch({type:"object.duplicate",objectIds:["table"]});const saved=parent.snapshot.value as CanvasDocument; + expect(saved.objects[1]!.document).toEqual(saved.objects[0]!.document); + expect(parseCanvasDocument(serializeCanvasDocument(saved))).toEqual(saved); + parent.dispatch({type:"object.remove",objectIds:["table"]});expect(sheet.dispatch({type:"cell.commit",rowId:"row-1",columnId:"column-1",value:"stale"}).ok).toBe(false); + parent.undo();expect((sheet.snapshot.value as SheetDocument).rows[0]!.cells["column-1"]).toBe("abc");release(); +}); diff --git a/packages/json-document-editing/tests/sheet-application.test.ts b/packages/json-document-editing/tests/sheet-application.test.ts index 62939542..6eec252d 100644 --- a/packages/json-document-editing/tests/sheet-application.test.ts +++ b/packages/json-document-editing/tests/sheet-application.test.ts @@ -9,5 +9,5 @@ test("document name participates in history and minimum axes stay editable",()=> }); test("rejects malformed restored sheet columns before rendering",()=>{ - expect(()=>createSheetEditor({columns:[{id:"a",label:{bad:true}}],rows:[]} as never)).toThrow("string ids and labels"); + expect(()=>createSheetEditor({columns:[{id:"a",label:{bad:true}}],rows:[]} as never)).toThrow(); }); diff --git a/packages/json-document-editing/tests/sheet-binding.test.ts b/packages/json-document-editing/tests/sheet-binding.test.ts new file mode 100644 index 00000000..92c51989 --- /dev/null +++ b/packages/json-document-editing/tests/sheet-binding.test.ts @@ -0,0 +1,48 @@ +import {expect,test,vi} from "vitest"; +import {applyPatch,createJSONDocument} from "@interactive-os/json-document"; +import {createSheetDocument} from "@interactive-os/json-document-sheet-document"; +import {createSheetEditor,createProjectedSheetEditor,planSheetIntent} from "../src/index.js"; + +test("planning does not mutate input or record history and matches standalone commands",()=>{ + const value=createSheetDocument({rows:2,columns:2}),editor=createSheetEditor(value),selection=editor.snapshot.selection; + for(const intent of [{type:"cell.commit",rowId:"row-1",columnId:"column-1",value:"hello"},{type:"row.insert",index:1},{type:"selection.set",rowId:"row-2",columnId:"column-2"}] as const){ + const before=JSON.stringify(value),plan=planSheetIntent(value,selection,intent);expect(plan.ok).toBe(true);if(!plan.ok)continue; + const independent=createSheetEditor(value);independent.dispatch(intent); + const applied=applyPatch(value,plan.plan.operations);expect(applied.ok && applied.value).toEqual(independent.snapshot.value); + expect(plan.plan.selectionAfter).toEqual(independent.snapshot.selection);expect(JSON.stringify(value)).toBe(before); + } + expect(editor.snapshot.canUndo).toBe(false); +}); +test("parent binding rejects writes atomically and distinguishes missing, invalid and readonly",()=>{ + const parent=createSheetEditor(createSheetDocument({rows:2,columns:2}));let candidate:unknown=parent.snapshot.value,readonly=false; + const write=vi.fn(()=>({ok:false,code:"denied"})); + const sheet=createProjectedSheetEditor({source:parent,read:()=>candidate,write,readOnly:()=>readonly}); + const selection=sheet.snapshot.selection; + expect(sheet.dispatch({type:"cell.commit",rowId:"row-1",columnId:"column-1",value:"x"})).toEqual({ok:false,code:"denied"}); + expect(sheet.snapshot.selection).toEqual(selection);expect(sheet.snapshot.value).toEqual(candidate); + sheet.dispatch({type:"selection.set",rowId:"row-2",columnId:"column-2"});expect(write).toHaveBeenCalledTimes(1); + readonly=true;expect(sheet.availability).toBe("readonly");expect(sheet.dispatch({type:"row.insert",index:0}).ok).toBe(false);expect(write).toHaveBeenCalledTimes(1); + candidate={bad:true};parent.dispatch({type:"sheet.rename",name:"changed"});expect(sheet.availability).toBe("invalid"); + candidate=null;parent.dispatch({type:"sheet.rename",name:"changed again"});expect(sheet.availability).toBe("missing"); +}); +test("parent projection validates the same schema before saving structural edits",()=>{ + const doc=createJSONDocument(createSheetDocument()),parent=createSheetEditor(doc),write=vi.fn(()=>({ok:true})); + const sheet=createProjectedSheetEditor({source:parent,read:()=>doc.value,write}); + expect(sheet.dispatch({type:"row.insert",index:0,row:{id:"new",cells:{}}}).ok).toBe(false);expect(write).not.toHaveBeenCalled(); +}); + +test("unchanged cell commits and selection changes do not write the parent",()=>{ + const parent=createSheetEditor(createSheetDocument()),write=vi.fn(()=>({ok:true})); + const sheet=createProjectedSheetEditor({source:parent,read:()=>parent.snapshot.value,write}); + expect(sheet.dispatch({type:"cell.commit",rowId:"row-1",columnId:"column-1",value:""}).ok).toBe(true); + expect(write).not.toHaveBeenCalled(); +}); + +test("readonly projected tables cannot undo or redo parent changes",()=>{ + const parent=createSheetEditor(createSheetDocument()); + parent.dispatch({type:"cell.commit",rowId:"row-1",columnId:"column-1",value:"saved"}); + const sheet=createProjectedSheetEditor({source:parent,read:()=>parent.snapshot.value,write:()=>({ok:false}),readOnly:()=>true}); + const before=parent.snapshot.value; + expect(sheet.undo()).toEqual({ok:false,code:"table.readonly"});expect(parent.snapshot.value).toBe(before); + parent.undo();const undone=parent.snapshot.value;expect(sheet.redo()).toEqual({ok:false,code:"table.readonly"});expect(parent.snapshot.value).toBe(undone); +}); diff --git a/packages/json-document-editing/tests/sheet-text-clipboard.test.ts b/packages/json-document-editing/tests/sheet-text-clipboard.test.ts new file mode 100644 index 00000000..f879f876 --- /dev/null +++ b/packages/json-document-editing/tests/sheet-text-clipboard.test.ts @@ -0,0 +1,7 @@ +import {expect,test} from "vitest"; +import {parseSheetClipboardText} from "../src/index.js"; +test("external spreadsheet text supports quoted newlines, escaped quotes, CRLF and rectangular padding",()=>{ + expect(parseSheetClipboardText('a\tb\r\nc\td\r\n')?.cells).toEqual([['a','b'],['c','d']]); + expect(parseSheetClipboardText('"a\tb"\t"line\nnext"\n"say ""hi"""')?.cells).toEqual([['a\tb','line\nnext'],['say "hi"','']]); + expect(parseSheetClipboardText('"unfinished')).toBeNull(); +}); diff --git a/packages/json-document-editing/tests/sheet-view.test.ts b/packages/json-document-editing/tests/sheet-view.test.ts new file mode 100644 index 00000000..ca1ecaa9 --- /dev/null +++ b/packages/json-document-editing/tests/sheet-view.test.ts @@ -0,0 +1,70 @@ +import {expect,test,vi} from "vitest"; +import {createJSONDocument} from "@interactive-os/json-document"; +import {createSheetEditor,createMarkdownTableEditor,createTextEditor,createCanvasSheet,createObjectEditor,createObjectSheetEditor,type SheetDocument} from "../src/index.js"; + +const initial:SheetDocument={columns:[{id:"a",label:"A"},{id:"b",label:"B"},{id:"c",label:"C"}],rows:[{id:"r1",cells:{a:"1a",b:"1b",c:"1c"}},{id:"r2",cells:{a:"2a",b:"2b",c:"2c"}},{id:"r3",cells:{a:"3a",b:"3b",c:"3c"}}]}; +const reversed={rowOrder:["r3","r2","r1"],columnOrder:["c","b","a"]}; + +test("Views share synchronous data and history but retain independent selection",()=>{ + const document=createJSONDocument(initial),owner=createSheetEditor(document),left=owner.createView(),right=owner.createView(reversed); + const changes=vi.fn();document.subscribe(changes);const seen=vi.fn();const unsubscribe=left.subscribe(seen); + left.dispatch({type:"selection.set",rowId:"r2",columnId:"b"});right.dispatch({type:"selection.set",rowId:"r3",columnId:"c"}); + const selected=left.snapshot.selection;expect(changes).not.toHaveBeenCalled(); + expect(right.dispatch({type:"cell.commit",rowId:"r3",columnId:"c",value:"changed"}).ok).toBe(true); + expect(left.snapshot.value).toBe(owner.snapshot.value);expect((left.snapshot.value as SheetDocument).rows[2]!.cells.c).toBe("changed"); + expect(left.snapshot.selection).toEqual(selected);expect(changes).toHaveBeenCalledTimes(1);expect(seen).toHaveBeenCalled(); + expect(left.undo().ok).toBe(true);expect(right.snapshot.value).toEqual(initial);expect(left.snapshot.selection).toEqual(selected); + expect(right.redo().ok).toBe(true);expect((left.snapshot.value as SheetDocument).rows[2]!.cells.c).toBe("changed");unsubscribe(); +}); + +test("reordered Grid navigation, copy, paste and fill address stable IDs",()=>{ + const owner=createSheetEditor(initial),view=owner.createView(reversed); + expect(view.grid.rowIds).toEqual(reversed.rowOrder);expect(view.snapshot.selection.focus).toEqual({rowId:"r3",columnId:"c"}); + view.dispatch({type:"selection.navigate",direction:"next"});expect(view.snapshot.selection.focus).toEqual({rowId:"r3",columnId:"b"}); + view.dispatch({type:"selection.range",range:{anchor:{rowId:"r3",columnId:"c"},focus:{rowId:"r2",columnId:"b"}}}); + expect(view.copy()?.text).toBe("3c\t3b\n2c\t2b");expect(view.selectedCells.map(cell=>cell.value)).toEqual(["3c","3b","2c","2b"]); + view.dispatch({type:"selection.set",rowId:"r3",columnId:"c"}); + expect(view.dispatch({type:"clipboard.paste",clipboard:{type:"application/vnd.interactive-os.sheet+json",cells:[["x","y"],["z","w"]],text:"x\ty\nz\tw"}}).ok).toBe(true); + const value=owner.snapshot.value as SheetDocument;expect(value.rows[2]!.cells).toEqual({a:"3a",b:"y",c:"x"});expect(value.rows[1]!.cells).toEqual({a:"2a",b:"w",c:"z"}); + expect(view.dispatch({type:"range.fill",source:{anchor:{rowId:"r3",columnId:"c"},focus:{rowId:"r3",columnId:"c"}},target:{anchor:{rowId:"r3",columnId:"c"},focus:{rowId:"r1",columnId:"c"}}}).ok).toBe(true); + expect((owner.snapshot.value as SheetDocument).rows.map(row=>row.cells.c)).toEqual(["x","x","x"]); + view.dispatch({type:"selection.row",rowId:"r2"});expect(view.copy()?.cells).toEqual([["x","w","2a"]]); +}); + +test("structure and Undo reconcile View order and remove stale selected IDs",()=>{ + const owner=createSheetEditor(initial),view=owner.createView(reversed),other=owner.createView(); + view.dispatch({type:"selection.set",rowId:"r3",columnId:"c"}); + other.dispatch({type:"row.delete",rowId:"r3"});expect(view.grid.rowIds).toEqual(["r2","r1"]);expect(view.snapshot.selection.focus).toBeNull(); + other.undo();expect(view.grid.rowIds).toEqual(reversed.rowOrder); + other.dispatch({type:"row.insert",index:0});expect(view.grid.rowIds).toEqual(["r3","r2","r1","row-1"]); + other.dispatch({type:"column.insert",index:0});expect(view.grid.columnIds).toEqual(["c","b","a","column-1"]); + expect(view.grid.rows.at(-1)!.cells).toHaveProperty("column-1"); +}); + +test("readonly Views cannot mutate shared history or bypass restrictions through nested Views",()=>{ + const owner=createSheetEditor(initial);owner.dispatch({type:"cell.commit",rowId:"r1",columnId:"a",value:"changed"}); + const view=owner.createView({readOnly:()=>true}),nested=view.createView({readOnly:()=>false}),before=owner.snapshot.value; + for(const editor of [view,nested]){ + expect(editor.availability).toBe("readonly");expect(editor.undo().ok).toBe(false);expect(editor.redo().ok).toBe(false); + expect(editor.dispatch({type:"selection.fill",value:"blocked"}).ok).toBe(false); + expect(editor.dispatch({type:"row.insert",index:0}).ok).toBe(false); + expect(editor.cut()?.result.ok).toBe(false);expect(editor.copy()).not.toBeNull(); + } + expect(owner.snapshot.value).toBe(before);expect(owner.undo().ok).toBe(true); +}); + +test("Markdown and Canvas table sources expose the same View API and parent Undo",()=>{ + const text=createTextEditor(createJSONDocument("| A | B |\n| --- | --- |\n| x | y |")),table=createMarkdownTableEditor(text,()=>0),view=table.createView(); + expect(view.dispatch({type:"cell.commit",rowId:"r1",columnId:"c0",value:"shared"}).ok).toBe(true);expect(text.text).toContain("shared");table.undo();expect(text.text).toContain("| x | y |"); + const parent=createObjectEditor({objects:[{...createCanvasSheet({x:0,y:0,width:400,height:200}),id:"t"}]}),canvas=createObjectSheetEditor(parent,"t"),embedded=canvas.createView(); + expect(embedded.dispatch({type:"cell.commit",rowId:"row-1",columnId:"column-1",value:"shared"}).ok).toBe(true); + expect((canvas.snapshot.value as SheetDocument).rows[0]!.cells["column-1"]).toBe("shared");embedded.undo();expect((canvas.snapshot.value as SheetDocument).rows[0]!.cells["column-1"]).toBe(""); +}); + +test("edits to the same cell from different Views remain separate Undo steps",()=>{ + const source=createSheetEditor(initial),left=source.createView(),right=source.createView(); + left.dispatch({type:"cell.commit",rowId:"r1",columnId:"a",value:"left"}); + right.dispatch({type:"cell.commit",rowId:"r1",columnId:"a",value:"right"}); + left.undo();expect((source.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("left"); + right.undo();expect(source.snapshot.value).toEqual(initial); +}); diff --git a/packages/json-document-editing/tsconfig.json b/packages/json-document-editing/tsconfig.json index ec9c0076..6663421b 100644 --- a/packages/json-document-editing/tsconfig.json +++ b/packages/json-document-editing/tsconfig.json @@ -10,7 +10,8 @@ { "path": "../json-document-calendar-document" }, { "path": "../json-document" }, { "path": "../json-document-selection" }, - { "path": "../json-document-markdown" } + { "path": "../json-document-markdown" }, + { "path": "../json-document-sheet-document" } ], "include": ["src/**/*.ts"] } diff --git a/packages/json-document-markdown-react/docs/api-reference.md b/packages/json-document-markdown-react/docs/api-reference.md index 8b6081fa..74f5058e 100644 --- a/packages/json-document-markdown-react/docs/api-reference.md +++ b/packages/json-document-markdown-react/docs/api-reference.md @@ -14,7 +14,7 @@ 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 +MarkdownCellEditor({ label, value, onValueChange, style, onKeyDown, onBlur, initialSelection }: SheetCellEditorProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element ``` ## `MarkdownComponents` diff --git a/packages/json-document-markdown-react/docs/editing.md b/packages/json-document-markdown-react/docs/editing.md index 4d36b55b..481aeff2 100644 --- a/packages/json-document-markdown-react/docs/editing.md +++ b/packages/json-document-markdown-react/docs/editing.md @@ -44,3 +44,5 @@ Markdown Web과 공용 contenteditable 투영을 사용합니다. `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`도 함께 로드해야 합니다. + +Bear의 rich Markdown 리스트는 같은 Markdown Web binding을 사용합니다. Tab은 앞 형제 아래로, Shift+Tab은 부모 다음 형제로 항목과 자식을 함께 이동합니다. Enter는 항목을 분할·이어 쓰며 빈 중첩 항목에서는 한 단계 밖으로, 최상위에서는 리스트 밖으로 나갑니다. 내어쓴 항목 뒤의 형제들은 원래 부모 아래에 남습니다. Tree의 펼침·선택 어포던스는 이 텍스트 편집 계약에 포함하지 않습니다. diff --git a/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx b/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx index 390ba366..4c9c9fff 100644 --- a/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx +++ b/packages/json-document-markdown-react/src/MarkdownCellEditor.tsx @@ -5,7 +5,7 @@ import {createMarkdownEditingBinding} from "@interactive-os/json-document-markdo 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) { +export function MarkdownCellEditor({label,value,onValueChange,style,onKeyDown,onBlur,initialSelection}: SheetCellEditorProps) { const root = useRef(null); const change = useRef(onValueChange);change.current = onValueChange; const [editor] = useState(() => createTextEditor(createJSONDocument(value))); @@ -16,7 +16,7 @@ export function MarkdownCellEditor({label,value,onValueChange,style,onKeyDown,on 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}); + element.focus();editor.select({anchor:initialSelection === "end" ? editor.text.length : 0,focus:editor.text.length}); return () => {unsubscribe();unbind();}; }, [editor]); useEffect(() => {if (editor.text !== value) editor.replace(value,editor.snapshot.selection);}, [editor,value]); diff --git a/packages/json-document-markdown-web/docs/api.md b/packages/json-document-markdown-web/docs/api.md index 676a1d38..9fb0294f 100644 --- a/packages/json-document-markdown-web/docs/api.md +++ b/packages/json-document-markdown-web/docs/api.md @@ -121,3 +121,5 @@ React `MarkdownEditingSurface`와 직접 Web 소비는 이 API를 함께 사용 [Usage](/demo/markdown-caret)의 소스에서 binding과 문법 명령까지 추적할 수 있습니다. `createMarkdownEditingBinding`은 Markdown의 목록 Enter 및 `indentMarkdownList`를 contenteditable의 `insertBreak`·`indent` 주입 경계에 연결합니다. Tab/Shift+Tab은 목록에서만 소비하고 IME 조합 중에는 실행하지 않습니다. source·선택 변경은 기존 editor.replace 한 번이므로 Undo/Redo 역시 한 번입니다. React와 직접 Web 소비가 같은 명령을 사용합니다. + +순서 없는 목록의 불릿은 리스트 깊이 1부터 채움·윤곽선을 번갈아 표시합니다. 중첩 번호 목록도 깊이에 포함하며, 들여쓰기/내어쓰기로 구조가 바뀌면 같은 DOM 투영에서 다시 계산합니다. 마커의 크기와 편집 전후 위치는 유지합니다. diff --git a/packages/json-document-markdown-web/src/markdown-editor.css b/packages/json-document-markdown-web/src/markdown-editor.css index e8662a43..e1942df2 100644 --- a/packages/json-document-markdown-web/src/markdown-editor.css +++ b/packages/json-document-markdown-web/src/markdown-editor.css @@ -63,6 +63,8 @@ block-size: var(--markdown-marker-size); vertical-align: -.12em; } +[data-markdown-bullet="fill"] { --markdown-bullet-fill: currentColor; } +[data-markdown-bullet="outline"] { --markdown-bullet-fill: transparent; } [data-markdown-list-style="unordered"]::before { content: ""; position: absolute; @@ -72,7 +74,9 @@ top: 50%; transform: translate(-50%, -50%); border-radius: 50%; - background: currentColor; + box-sizing: border-box; + border: 1px solid currentColor; + background: var(--markdown-bullet-fill, currentColor); } [data-markdown-list-style="ordered"] { min-inline-size: var(--markdown-marker-size); text-align: end; font-variant-numeric: tabular-nums; } [data-markdown-marker="task"] { padding-inline-end: .25em; box-sizing: content-box; } diff --git a/packages/json-document-markdown-web/src/source-runs.ts b/packages/json-document-markdown-web/src/source-runs.ts index 13fc8300..4f075d3b 100644 --- a/packages/json-document-markdown-web/src/source-runs.ts +++ b/packages/json-document-markdown-web/src/source-runs.ts @@ -131,18 +131,18 @@ export function sourceRuns(projection: MarkdownProjection, editableTables = fals return [parent?.kind === "table" || parent?.kind === "tableRow" ? {...run, attributes:{...run.attributes, "data-markdown-table-gap":""}} : run]; }; - const children = (nodes: ReadonlyArray, from: number, to: number, parent?: MarkdownNode, table?: MarkdownNode): SourceRun[] => { + const children = (nodes: ReadonlyArray, from: number, to: number, parent?: MarkdownNode, table?: MarkdownNode, listDepth = 0): SourceRun[] => { const result: SourceRun[] = []; let cursor = from; for (const [index, node] of nodes.entries()) { if (node.from > cursor) result.push(...gap(cursor, node.from, parent, table)); - result.push(visit(node, parent, index, table)); + result.push(visit(node, parent, index, table, listDepth)); cursor = node.to; } if (cursor < to) result.push(...gap(cursor, to, parent, table)); return result; }; - const visit = (node: MarkdownNode, parent?: MarkdownNode, index = 0, table?: MarkdownNode): SourceRun => { + const visit = (node: MarkdownNode, parent?: MarkdownNode, index = 0, table?: MarkdownNode, listDepth = 0): 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: [ @@ -153,7 +153,11 @@ export function sourceRuns(projection: MarkdownProjection, editableTables = fals }; 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"; + if (node.kind === "list") { + listDepth++; + attributes.role = "list"; + attributes["data-markdown-bullet"] = listDepth % 2 ? "fill" : "outline"; + } if (node.kind === "listItem") { attributes.role = "listitem"; if (node.checked !== null && node.checked !== undefined) attributes["data-checked"] = String(node.checked); @@ -176,7 +180,7 @@ export function sourceRuns(projection: MarkdownProjection, editableTables = fals const title = node.title ?? definition?.title; if (title) attributes.title = title; let content: SourceRun[]; - if (node.children) content = children(node.children, node.from, node.to, node, node.kind === "table" ? node : table); + if (node.children) content = children(node.children, node.from, node.to, node, node.kind === "table" ? node : table, listDepth); else if (image && url) { content = [raw(node.from, node.to, node, true), { kind: "imagePreview", tag: "img", from: node.from, to: node.from, diff --git a/packages/json-document-markdown-web/tests/editing-binding.test.ts b/packages/json-document-markdown-web/tests/editing-binding.test.ts index 13dfebeb..fd7f0bfe 100644 --- a/packages/json-document-markdown-web/tests/editing-binding.test.ts +++ b/packages/json-document-markdown-web/tests/editing-binding.test.ts @@ -66,3 +66,14 @@ test("IME owns Tab until list text is committed, then Enter uses the list comman vi.runAllTimers(); expect(editor.text).toBe("- one\n- 한\n- "); }); + +test("empty nested Enter is one history step and restores its source selection",()=>{ + const original="- parent\n - "; + const {root,editor}=setup(original); + root.dispatchEvent(new InputEvent("beforeinput",{inputType:"insertParagraph",cancelable:true})); + expect(editor.text).toBe("- parent\n- "); + expect(editor.snapshot.selection).toEqual({anchor:11,focus:11}); + editor.undo();expect(editor.text).toBe(original); + expect(editor.snapshot.selection).toEqual({anchor:original.length,focus:original.length}); + editor.redo();expect(editor.text).toBe("- parent\n- "); +}); diff --git a/packages/json-document-markdown/docs/api.md b/packages/json-document-markdown/docs/api.md index 9ecfc5bb..71fffc75 100644 --- a/packages/json-document-markdown/docs/api.md +++ b/packages/json-document-markdown/docs/api.md @@ -120,8 +120,10 @@ DOM·History는 소유하지 않으며 소비자가 반환값을 기존 TextEdit ## 목록 편집 -`insertMarkdownParagraph(source, selection)`는 파서가 인식한 목록에서도 동작합니다. 본문은 같은 항목 문법으로 이어 쓰고 번호 목록은 다음 번호, task는 미체크 상태로 이어 씁니다. 빈 항목은 목록 접두사를 제거하고 일반 문단 경계를 남깁니다. 감싼 인용 접두사는 유지합니다. 코드·HTML 내부는 목록처럼 보이는 문자만으로 이어 쓰지 않습니다. 빈 task 초안 `[ ]`도 목록 종료 대상으로 처리합니다. +`insertMarkdownParagraph(source, selection)`는 파서가 인식한 목록에서도 동작합니다. 본문은 같은 항목 문법으로 이어 쓰고 번호 목록은 다음 번호, task는 미체크 상태로 이어 씁니다. 빈 중첩 항목은 한 단계 내어쓰고, 최상위 빈 항목은 목록 접두사를 제거해 일반 문단 경계를 남깁니다. 감싼 인용 접두사는 유지합니다. 코드·HTML 내부는 목록처럼 보이는 문자만으로 이어 쓰지 않습니다. 빈 task 초안 `[ ]`도 목록 종료 대상으로 처리합니다. -`indentMarkdownList(source, selection, direction: "indent" | "outdent"): MarkdownSourceEdit | null`은 선택한 형제 항목과 하위 내용을 함께 이동합니다. Tab은 앞 형제의 본문 들여쓰기 폭을 사용하고 Shift+Tab은 부모 목록으로 승격합니다. 중첩 번호는 1부터, 승격 번호는 부모 다음부터 시작합니다. 첫 항목의 들여쓰기와 최상위 내어쓰기는 변경 없이 반환하며 목록 밖에서는 `null`입니다. 원문 CRLF와 선택 방향을 보존하며 변경된 들여쓰기는 공백으로 표현합니다. +`indentMarkdownList(source, selection, direction: "indent" | "outdent"): MarkdownSourceEdit | null`은 선택한 형제 항목과 하위 내용을 함께 이동합니다. Tab은 앞 형제의 본문 들여쓰기 폭을 사용하고 Shift+Tab은 부모의 전체 하위 내용 다음 형제로 승격합니다. 뒤에 남는 형제와 부모의 후속 문단은 원래 부모 아래에 유지합니다. 중첩 번호는 1부터, 승격 번호는 부모 다음부터 시작합니다. 첫 항목의 들여쓰기와 최상위 내어쓰기는 변경 없이 반환하며 목록 밖에서는 `null`입니다. 원문 CRLF와 선택 방향을 보존하며 변경된 들여쓰기는 공백으로 표현합니다. `MarkdownSourceEdit`는 `{ value, selection: { anchor, focus } }`입니다. 원문·선택은 UTF-16 offset이며 원문 범위 밖 선택은 RangeError입니다. 명령은 DOM·키 이벤트·History를 소유하지 않습니다. [Usage](/demo/markdown-caret)에서 Web binding을 통해 실행합니다. + +Bear의 빈 중첩 목록 초안은 한 글자를 입력하기 전 setext 제목 밑줄이나 lazy continuation으로 파싱될 수 있습니다. 목록 내부의 빈 단일 마커 편집 문맥에서만 목록 의도를 복원하며, 일반 제목·코드 문법을 변경하지 않습니다. [Bear Usage](/applications/bear)에서 Tab/Shift+Tab·Enter를 실행할 수 있고 원문 선택과 Undo/Redo는 기존 TextEditor가 소유합니다. diff --git a/packages/json-document-markdown/src/list-editing.ts b/packages/json-document-markdown/src/list-editing.ts index 2e3468fe..debf849b 100644 --- a/packages/json-document-markdown/src/list-editing.ts +++ b/packages/json-document-markdown/src/list-editing.ts @@ -1,6 +1,6 @@ import { assertMarkdownSelection, type MarkdownSourceEdit } from "./source-edit.js"; import { projectMarkdown, type MarkdownProjection } from "./projection.js"; -import type { MarkdownNode } from "./nodes.js"; +import { editNode, type MarkdownNode } from "./nodes.js"; type Selection = MarkdownSourceEdit["selection"]; interface Item { @@ -38,6 +38,18 @@ function items(source: string, projection: MarkdownProjection): Item[] { visit(projection.nodes,null,null); return result; } +/** Empty nested markers can parse as setext/lazy text until their first character is typed. */ +function editingItems(source:string, projection:MarkdownProjection, offset:number):Item[] { + const parsed=items(source,projection), start=lineStart(source,offset), end=lineEnd(source,offset); + if(parsed.some(item=>item.lineFrom === start) || !itemAt(source,parsed,offset) + || !/^(?:[ \t]*>[ \t]?)*[ \t]*(?:[-+*]|\d{1,9}[.)])[ \t]*(?:\[[ xX]\][ \t]*)?\r?$/.test(source.slice(start,end))) return parsed; + const at=end-(source[end-1] === "\r" ? 1 : 0); + const placeholder=/[ \t]$/.test(source.slice(start,at)) ? "x":" x"; + const draft=projectMarkdown(source.slice(0,at)+placeholder+source.slice(at)); + const map=(position:number)=>position>at ? Math.max(at,position-placeholder.length):position; + return items(source,{...draft,source,nodes:draft.nodes.map(node=>editNode(node,at,at+placeholder.length,"")), + markers:draft.markers.map(marker=>({...marker,from:map(marker.from),to:map(marker.to)}))}); +} function itemAt(source: string, entries: Item[], offset: number): Item | undefined { return entries.filter(item => offset >= item.lineFrom && offset <= lineEnd(source,item.node.to)).at(-1); } @@ -49,11 +61,12 @@ function insideCode(nodes: readonly MarkdownNode[], offset: number): boolean { export function continueMarkdownList(source: string, selection: Selection, projection: MarkdownProjection): MarkdownSourceEdit | null { const from = Math.min(selection.anchor,selection.focus), to = Math.max(selection.anchor,selection.focus); if (insideCode(projection.nodes,from)) return null; - const entry = itemAt(source,items(source,projection),from); + const entry = itemAt(source,editingItems(source,projection,from),from); if (!entry || from < entry.bodyFrom) return null; const end = lineEnd(source,from), start = lineStart(source,from); const newline = source.includes("\r\n") ? "\r\n" : "\n"; if (from === to && start === entry.lineFrom && !source.slice(entry.bodyFrom,end).trim()) { + if (entry.parent) return indentMarkdownList(source,selection,"outdent"); // Remove only this list prefix; an enclosing quote remains a quote. const quote = source.slice(entry.lineFrom,entry.quoteEnd); const boundary = entry.lineFrom > 0 && !quote ? newline : ""; @@ -81,7 +94,8 @@ export function continueMarkdownList(source: string, selection: Selection, proje /** Indent/outdent selected sibling items with their descendants; null outside lists. */ export function indentMarkdownList(source: string, selection: Selection, direction: "indent" | "outdent"): MarkdownSourceEdit | null { assertMarkdownSelection(source,selection); - const projection = projectMarkdown(source), entries = items(source,projection); + const projection = projectMarkdown(source); + const entries = editingItems(source,projection,Math.min(selection.anchor,selection.focus)); const from = Math.min(selection.anchor,selection.focus), to = Math.max(selection.anchor,selection.focus); if (insideCode(projection.nodes,from)) return null; const first = itemAt(source,entries,from), last = itemAt(source,entries,to > from ? to-1 : to); @@ -96,6 +110,22 @@ export function indentMarkdownList(source: string, selection: Selection, directi const selected = entries.filter(item => item.list === a!.list && item.index >= a!.index && item.index <= b!.index); const previous = entries.find(item => item.list === a!.list && item.index === a!.index-1); if (direction === "indent" && !previous || direction === "outdent" && !a.parent) return {value:source,selection}; + // Promote after the complete parent subtree. Merely removing indentation here + // would silently adopt the following siblings/paragraphs as children of the moved item. + if (direction === "outdent") { + const tailFrom = lineEnd(source,b.node.to)+1; + const end = lineEnd(source,a.parent!.node.to); + const parentEnd = end-(source[end-1] === "\r" ? 1 : 0); + if (tailFrom < parentEnd) { + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + const start = a.lineFrom; + const moved = source.slice(start,tailFrom).replace(/\r?\n$/, ""); + const tail = source.slice(tailFrom,parentEnd); + const value = source.slice(0,start)+tail+newline+moved+source.slice(parentEnd); + const map = (offset:number) => start+tail.length+newline.length+Math.min(offset-start,moved.length); + return indentMarkdownList(value,{anchor:map(selection.anchor),focus:map(selection.focus)},direction); + } + } const width = direction === "indent" ? previous!.contentIndent : a.indentColumns-a.parent!.indentColumns; if (width <= 0) return {value:source,selection}; const edits: Array<{from:number; to:number; insert:string}> = []; diff --git a/packages/json-document-markdown/tests/list-editing.test.ts b/packages/json-document-markdown/tests/list-editing.test.ts index 02c45dfa..783d05b8 100644 --- a/packages/json-document-markdown/tests/list-editing.test.ts +++ b/packages/json-document-markdown/tests/list-editing.test.ts @@ -48,3 +48,38 @@ test("Tab accounts for tab-expanded list marker padding", () => { expect(result.value).toBe("-\tone\n -\ttwo"); expect(indentMarkdownList(result.value,result.selection,"outdent")?.value).toBe("-\tone\n-\ttwo"); }); + +test("empty nested items leave one level at a time, preserving task and quote prefixes",()=>{ + for(const [source,expected] of [ + ["- parent\n - ","- parent\n- "], + ["- parent\n -","- parent\n-"], + ["- parent\n - child\n - ","- parent\n - child\n - "], + ["> - parent\n> - [ ] ","> - parent\n> - [ ] "], + ["1. parent\n 1. ","1. parent\n2. "], + ["- parent\r\n - ","- parent\r\n- "], + ]) { + const result=enter(source!);expect(result.value).toBe(expected); + expect(result.selection).toEqual({anchor:expected!.length,focus:expected!.length}); + } +}); +test("outdent preserves the following siblings under the original parent",()=>{ + const source="- parent\n - moving\n - child\n - staying\n- next"; + const at=source.indexOf("moving")+3; + const result=indent(source,"outdent",at)!; + expect(result.value).toBe("- parent\n - staying\n- moving\n - child\n- next"); + expect(result.selection.focus).toBe(result.value.indexOf("moving")+3); +}); +test("outdent a backward sibling range preserves trailing parent content and CRLF",()=>{ + const source="- parent\r\n - first\r\n - second\r\n - stays\r\n\r\n paragraph\r\n- next"; + const result=indent(source,"outdent",source.indexOf("second")+6,source.indexOf("first"))!; + expect(result.value).toBe("- parent\r\n - stays\r\n\r\n paragraph\r\n- first\r\n- second\r\n- next"); + expect(result.selection.anchor).toBe(result.value.indexOf("second")+6); + expect(result.selection.focus).toBe(result.value.indexOf("first")); +}); + +test("empty middle item promotes past remaining siblings instead of adopting them",()=>{ + const source="> - parent\n> - \n> - stays"; + const result=enter(source,source.indexOf("\n> - stays")); + expect(result.value).toBe("> - parent\n> - stays\n> - "); + expect(result.selection.focus).toBe(result.value.length); +}); diff --git a/packages/json-document-object-document/docs/api-reference.md b/packages/json-document-object-document/docs/api-reference.md index 8f4b0a54..f5f268e4 100644 --- a/packages/json-document-object-document/docs/api-reference.md +++ b/packages/json-document-object-document/docs/api-reference.md @@ -49,12 +49,13 @@ type CanvasObjectDraft = ObjectDraft & ( | (CanvasTextFormat & { readonly kind: "rectangle" | "ellipse" | "sticky-note"; readonly textColor?: string; readonly strokeColor?: string; readonly strokeWidth?: number }) | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } | { readonly kind: "image"; readonly source: string } + | { readonly kind: "embedded-document"; readonly documentType: string; readonly document: JSONValue } ); ``` ## `CanvasObjectKind` ```ts -type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image"; +type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image" | "embedded-document"; ``` ## `CanvasTextFormat` @@ -65,6 +66,11 @@ interface CanvasTextFormat { readonly textAlign?: "left" | "center" | "right"; } ``` +## `createCanvasEmbeddedDocument` + +```ts +createCanvasEmbeddedDocument(documentType: string, document: JSONValue, bounds: ObjectBounds, label: string): Extract +``` ## `createCanvasImage` ```ts @@ -73,7 +79,7 @@ createCanvasImage(image: { readonly source: string; readonly width: number; read ## `createCanvasObject` ```ts -createCanvasObject(kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string; }): CanvasObjectDraft +createCanvasObject(kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string; }): CanvasObjectDraft ``` ## `createCanvasPath` @@ -126,6 +132,7 @@ type ObjectOperation = | { readonly type: "fill"; readonly objectIds: ReadonlyArray; readonly color: string } | { readonly type: "style"; readonly objectIds: ReadonlyArray; readonly style: Partial } | { readonly type: "remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "embedded-document"; readonly objectId: string; readonly document: JSONValue } | { readonly type: "text"; readonly objectId: string; readonly text: string } | { readonly type: "replace"; readonly document: ObjectDocument }; ``` diff --git a/packages/json-document-object-document/docs/api.md b/packages/json-document-object-document/docs/api.md index c2cef7b2..a4713ec6 100644 --- a/packages/json-document-object-document/docs/api.md +++ b/packages/json-document-object-document/docs/api.md @@ -129,3 +129,11 @@ strokeWidth 0을 적용하면 도형만 바꾸지 않고 전체를 거절합니 페이지·줌·팬·그룹·회전·정렬·snap·레이어 패널·PPTX·협업은 이 Canvas slice 범위 밖입니다. 이 RC 프로파일은 독립 구현 간 Stable wire conformance를 선언하지 않습니다. Annotation의 source/selector/body 모델도 Canvas에 통합하지 않습니다. + +### 내장 문서 객체 + +`kind: "embedded-document"`는 `documentType: string`과 `document: JSONValue`를 공간 객체 안에 보관합니다. `createCanvasEmbeddedDocument(documentType, document, bounds, label)`이 초안을 생성합니다. Object 모델은 payload를 보존하고 구체 문서 schema를 중복 정의하지 않습니다. `embedded-document` operation은 해당 객체의 document 필드만 원자적으로 교체합니다. + +Editing의 `createCanvasSheet` / `createObjectSheetEditor`가 `sheet/1` payload 모델과 부모 History 연결을 소유합니다. Canvas는 `CanvasSheetObject`로 동일 SheetHand를 SVG 안에 조합합니다. 내장 문서는 객체 복제·Clipboard·JSON 직렬화·변형에서도 유지됩니다. 알 수 없는 documentType은 payload를 보존하며, 소비자가 지원 여부를 표시합니다. + +생성 중 한 축만 움직인 드래그도 처리할 수 있도록 `createCanvasEmbeddedDocument`는 유한한 너비·높이를 최소 1 layout 단위로 보정합니다. 일반 도형 생성과 같은 규칙이며, 저장 문서 검증의 양수 조건과 비유한 좌표 거부는 유지합니다. diff --git a/packages/json-document-object-document/src/embedded-document.ts b/packages/json-document-object-document/src/embedded-document.ts new file mode 100644 index 00000000..e66d79af --- /dev/null +++ b/packages/json-document-object-document/src/embedded-document.ts @@ -0,0 +1,8 @@ +import type {JSONValue} from "@interactive-os/json-document"; +import type {CanvasObjectDraft, ObjectBounds} from "./object-model.js"; + +/** Spatial container for a separately owned document. Like shape creation, finite degenerate extents become one layout unit. */ +export function createCanvasEmbeddedDocument(documentType: string, document: JSONValue, bounds: ObjectBounds, label: string): Extract { + if (!documentType || ![bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)) throw new TypeError("Invalid embedded document bounds/type."); + return {kind:"embedded-document",documentType,document,...bounds,width:Math.max(1,bounds.width),height:Math.max(1,bounds.height),label,color:"transparent"}; +} diff --git a/packages/json-document-object-document/src/index.ts b/packages/json-document-object-document/src/index.ts index cdc88f23..c90d9523 100644 --- a/packages/json-document-object-document/src/index.ts +++ b/packages/json-document-object-document/src/index.ts @@ -3,3 +3,4 @@ export { assertObjectDocument, assertCanvasDocument, assertCanvasImageSource, pa export { createCanvasObject, createCanvasPath, createCanvasImage, projectObject, projectObjectText, transformObject, type ObjectTransform, type ObjectTextProjection } from "./object-projection.js"; export { planObjectOperation, type ObjectOperation, type ObjectOperationPlan } from "./object-operation.js"; export { getObjectStyle, readObjectStyle, assertObjectStyle, type ObjectStyle, type ObjectStyleSelection } from "./object-style.js"; +export {createCanvasEmbeddedDocument} from "./embedded-document.js"; diff --git a/packages/json-document-object-document/src/object-model.ts b/packages/json-document-object-document/src/object-model.ts index 662404c0..af96b22b 100644 --- a/packages/json-document-object-document/src/object-model.ts +++ b/packages/json-document-object-document/src/object-model.ts @@ -26,7 +26,7 @@ export interface ObjectDocument extends Record { readonly objects: ReadonlyArray; } -export type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image"; +export type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image" | "embedded-document"; export interface CanvasTextFormat { readonly fontSize?: number; @@ -39,6 +39,7 @@ export type CanvasObjectDraft = ObjectDraft & ( | (CanvasTextFormat & { readonly kind: "rectangle" | "ellipse" | "sticky-note"; readonly textColor?: string; readonly strokeColor?: string; readonly strokeWidth?: number }) | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } | { readonly kind: "image"; readonly source: string } + | { readonly kind: "embedded-document"; readonly documentType: string; readonly document: JSONValue } ); export type CanvasObject = CanvasObjectDraft & { readonly id: string }; diff --git a/packages/json-document-object-document/src/object-operation.ts b/packages/json-document-object-document/src/object-operation.ts index 3bb1143d..0498917a 100644 --- a/packages/json-document-object-document/src/object-operation.ts +++ b/packages/json-document-object-document/src/object-operation.ts @@ -1,4 +1,4 @@ -import { applyPatch, buildPointer, jsonEqual, type JSONPatchOperation } from "@interactive-os/json-document"; +import { applyPatch, buildPointer, jsonEqual, type JSONValue, type JSONPatchOperation } from "@interactive-os/json-document"; import type { DocumentObject, ObjectDocument } from "./object-model.js"; import { projectObjectText, transformObject, type ObjectTransform } from "./object-projection.js"; import { assertObjectDocument } from "./object-validation.js"; @@ -10,6 +10,7 @@ export type ObjectOperation = | { readonly type: "fill"; readonly objectIds: ReadonlyArray; readonly color: string } | { readonly type: "style"; readonly objectIds: ReadonlyArray; readonly style: Partial } | { readonly type: "remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "embedded-document"; readonly objectId: string; readonly document: JSONValue } | { readonly type: "text"; readonly objectId: string; readonly text: string } | { readonly type: "replace"; readonly document: ObjectDocument }; @@ -30,7 +31,7 @@ export function planObjectOperation(document: ObjectDocument, operation: ObjectO } else if (operation.type === "insert") { operation.objects.forEach((object, index) => operations.push({ op: "add", path: `/objects/${objects.length + index}`, value: object })); } else { - const ids = operation.type === "text" ? [operation.objectId] : operation.objectIds; + const ids = (operation.type === "text" || operation.type === "embedded-document") ? [operation.objectId] : operation.objectIds; const targets = new Set(ids); if (ids.some((id) => !objects.some((object) => object.id === id))) return { ok: false, code: "selection.object-not-found" }; for (let index = objects.length - 1; index >= 0; index--) { @@ -51,6 +52,9 @@ export function planObjectOperation(document: ObjectDocument, operation: ObjectO const effective = current[key as keyof ObjectStyle]; if (effective !== undefined && effective !== value) operations.push({ op: "add", path: buildPointer(["objects", index, key]), value }); } + } else if (operation.type === "embedded-document") { + if (object.kind !== "embedded-document") return {ok:false,code:"object.not-embedded-document"}; + if (!jsonEqual(object.document!,operation.document)) operations.push({op:"replace",path:buildPointer(["objects",index,"document"]),value:operation.document}); } else if (operation.type === "text") { if (!projectObjectText(object)) return { ok: false, code: "object.not-text" }; if (operation.text !== object.label) operations.push({ op: "replace", path: buildPointer(["objects", index, "label"]), value: operation.text }); diff --git a/packages/json-document-object-document/src/object-projection.ts b/packages/json-document-object-document/src/object-projection.ts index aa878226..793aa32a 100644 --- a/packages/json-document-object-document/src/object-projection.ts +++ b/packages/json-document-object-document/src/object-projection.ts @@ -49,7 +49,7 @@ export function projectObjectText(object: DocumentObject): ObjectTextProjection } export function createCanvasObject( - kind: Exclude, + kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string }, ): CanvasObjectDraft { diff --git a/packages/json-document-object-document/src/object-style.ts b/packages/json-document-object-document/src/object-style.ts index 12cfa570..de4ce370 100644 --- a/packages/json-document-object-document/src/object-style.ts +++ b/packages/json-document-object-document/src/object-style.ts @@ -20,7 +20,7 @@ export function getObjectStyle(object: DocumentObject): Partial { const color = object.color ?? "transparent"; const text = { fontSize: (object.fontSize ?? 24) as number, fontWeight: (object.fontWeight ?? 400) as 400 | 700, textAlign: (object.textAlign ?? (object.kind === "rectangle" || object.kind === "ellipse" ? "center" : "left")) as ObjectStyle["textAlign"] }; switch (object.kind) { - case "image": return {}; + case "image": case "embedded-document": return {}; case "text": return { color, ...text }; case "rectangle": case "ellipse": case "sticky-note": return { color, ...text, textColor: (object.textColor ?? "#253044") as string, strokeColor: (object.strokeColor ?? "#000000") as string, strokeWidth: (object.strokeWidth ?? 0) as number }; case "path": return { color, strokeWidth: object.strokeWidth as number }; diff --git a/packages/json-document-object-document/src/object-validation.ts b/packages/json-document-object-document/src/object-validation.ts index adfc0bb7..5a30d2f6 100644 --- a/packages/json-document-object-document/src/object-validation.ts +++ b/packages/json-document-object-document/src/object-validation.ts @@ -27,11 +27,12 @@ export function assertObjectDocument(value: unknown): void { if ((object.width as number) < 0 || (object.height as number) < 0) throw new TypeError(`Object dimensions must not be negative: ${JSON.stringify(object.id)}.`); if (object.kind !== undefined) { if (typeof object.color !== "string") throw new TypeError("Canvas objects require a color string."); - if (!["text", "rectangle", "ellipse", "sticky-note", "path", "image"].includes(object.kind as string)) throw new TypeError("Unknown Object kind."); + if (!["text", "rectangle", "ellipse", "sticky-note", "path", "image", "embedded-document"].includes(object.kind as string)) throw new TypeError("Unknown Object kind."); if (!positive(object.width) || !positive(object.height)) throw new TypeError("Canvas object dimensions must be positive."); if (object.kind === "text" && !positive(object.fontSize)) throw new TypeError("Text fontSize must be positive and finite."); const styleKeys = Object.keys(getObjectStyle(object as unknown as DocumentObject)).filter((key) => key !== "color"); assertObjectStyle(Object.fromEntries(styleKeys.filter((key) => Object.hasOwn(object, key)).map((key) => [key, object[key]]))); + if (object.kind === "embedded-document" && (typeof object.documentType !== "string" || !object.documentType || !("document" in object))) throw new TypeError("Embedded objects require a document type and value."); if (object.kind === "image") assertCanvasImageSource(object.source); if (object.kind === "path") { if (!positive(object.strokeWidth) || !Array.isArray(object.points) || object.points.length < 2) throw new TypeError("Path requires a positive strokeWidth and at least two points."); diff --git a/packages/json-document-react/src/use-editing.ts b/packages/json-document-react/src/use-editing.ts index f8248b73..b7ba6ff6 100644 --- a/packages/json-document-react/src/use-editing.ts +++ b/packages/json-document-react/src/use-editing.ts @@ -1,3 +1,4 @@ +import {selectionOperationFromModifiers} from "@interactive-os/json-document-web"; import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; import type { JSONValue } from "@interactive-os/json-document"; import type { EditingSnapshot } from "@interactive-os/json-document-editing"; @@ -116,9 +117,7 @@ export interface Editing( diff --git a/packages/json-document-sheet-document/LICENSE b/packages/json-document-sheet-document/LICENSE new file mode 100644 index 00000000..334271fb --- /dev/null +++ b/packages/json-document-sheet-document/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 json-document contributors + +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-document/README.md b/packages/json-document-sheet-document/README.md new file mode 100644 index 00000000..6eb0d9db --- /dev/null +++ b/packages/json-document-sheet-document/README.md @@ -0,0 +1,13 @@ +# Sheet Document + +표의 정본 스키마·타입·검증·생성 모듈입니다. Bear, Canvas와 독립 Sheet는 같은 +`SheetDocument`를 편집하고 저장 형식의 차이만 Editing의 문서 연결이 처리합니다. + +- [계약과 Usage](docs/api.md) +- [Public API](docs/api-reference.md) + +```ts +import {createSheetDocument, sheetDocumentSchema} from "@interactive-os/json-document-sheet-document"; +const sheet = createSheetDocument({rows: 4, columns: 3}); +const restored = sheetDocumentSchema.parse(JSON.parse(JSON.stringify(sheet))); +``` diff --git a/packages/json-document-sheet-document/docs/api-reference.md b/packages/json-document-sheet-document/docs/api-reference.md new file mode 100644 index 00000000..d939728f --- /dev/null +++ b/packages/json-document-sheet-document/docs/api-reference.md @@ -0,0 +1,74 @@ +# @interactive-os/json-document-sheet-document API + +**탐색 분류:** Document Types + +표 정본 스키마·타입·검증·생성의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 저장소의 아키텍처 등록에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-sheet-document/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `assertSheetDocument` + +```ts +assertSheetDocument(value: unknown): asserts value is SheetDocument +``` +## `createSheetColumn` + +```ts +createSheetColumn(document: SheetDocument): SheetColumn +``` +## `createSheetDocument` + +```ts +createSheetDocument(options?: CreateSheetDocumentOptions): SheetDocument +``` +## `CreateSheetDocumentOptions` + +```ts +interface CreateSheetDocumentOptions { + readonly rows?:number; + readonly columns?:number; + readonly name?:string; + readonly columnWidth?:number; + readonly rowHeight?:number; +} +``` +## `createSheetRow` + +```ts +createSheetRow(document: SheetDocument): SheetRow +``` +## `SheetColumn` + +```ts +type SheetColumn = z.infer; +``` +## `sheetColumnLabel` + +```ts +sheetColumnLabel(index: number): string +``` +## `sheetColumnSchema` + +```ts +const sheetColumnSchema: z.ZodReadonly>>> +``` +## `SheetDocument` + +```ts +type SheetDocument = z.infer; +``` +## `sheetDocumentSchema` + +```ts +const sheetDocumentSchema: z.ZodReadonly>>>>>; rows: z.ZodReadonly<...>; }, z.core.$catchall<...>>> +``` +## `SheetRow` + +```ts +type SheetRow = z.infer; +``` +## `sheetRowSchema` + +```ts +const sheetRowSchema: z.ZodReadonly>>; }, z.core.$catchall>>> +``` diff --git a/packages/json-document-sheet-document/docs/api.md b/packages/json-document-sheet-document/docs/api.md new file mode 100644 index 00000000..de2915e0 --- /dev/null +++ b/packages/json-document-sheet-document/docs/api.md @@ -0,0 +1,36 @@ +# Sheet Document 계약 + +`sheetDocumentSchema`가 표의 정본 런타임 스키마입니다. `SheetDocument`, `SheetRow`, +`SheetColumn`은 각 스키마에서 추론하므로 소비자별 타입이나 검증 규칙을 만들지 않습니다. +Zod 스키마를 직접 조합할 수 있으며 `assertSheetDocument`는 원본 객체를 교체하지 않고 +JSON 값과 스키마를 검증합니다. 스키마의 parse는 검증된 값을 반환합니다. + +문서는 `columns`와 `rows`, 행은 `id`와 `cells`, 열은 `id`와 `label`을 가집니다. +행·열 ID는 각 축에서 고유하고 비어 있지 않아야 합니다. 모든 행은 등록된 열의 셀을 +가져야 합니다. 셀과 확장 속성은 JSON 값입니다. 기존 문서의 ID와 JSON 구조를 유지하며 +추가 discriminator를 요구하지 않습니다. 저장된 width/height는 양의 유한수입니다. + +`createSheetDocument`는 빈 셀을 가진 표를 만듭니다. 행·열 수, 제목과 초기 크기는 +Host 정책 값으로 전달합니다. `createSheetRow`, `createSheetColumn`, `sheetColumnLabel`도 +이 모듈이 소유합니다. Editing의 기존 타입·생성 함수 export는 호환 re-export입니다. + +```ts +import {createSheetDocument, sheetDocumentSchema} from "@interactive-os/json-document-sheet-document"; +const sheet = createSheetDocument({rows: 40, columns: 12, columnWidth: 120, rowHeight: 32}); +const validated = sheetDocumentSchema.parse(sheet); +``` + +실행 Usage: [Sheet 앱](/applications/sheet), [표 편집](/demo/sheet). +Source: `packages/json-document-sheet-document/src/schema.ts`, `src/create.ts`. + +```text +Sheet Document (schema -> types, validation, creation) + | +Editing (Intent, selection, change plans, document binding) + | +Affordance (input meaning) + Sheet Hand (presentation) + | +Sheet app / Markdown projection / Canvas embedded document +``` + +이 모듈은 History, DOM, 키 입력, Canvas 좌표나 Markdown 원문을 소유하지 않습니다. diff --git a/packages/json-document-sheet-document/package.json b/packages/json-document-sheet-document/package.json new file mode 100644 index 00000000..392f8f6a --- /dev/null +++ b/packages/json-document-sheet-document/package.json @@ -0,0 +1,52 @@ +{ + "name": "@interactive-os/json-document-sheet-document", + "version": "0.1.0-rc.0", + "description": "Canonical Sheet schema, validation and document construction without editing or UI.", + "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-document" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "docs", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b tsconfig.json", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "test": "vitest run --config vitest.config.ts", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { + "@interactive-os/json-document": "^3.0.0" + }, + "dependencies": { + "zod": "^4.0.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + } +} diff --git a/packages/json-document-sheet-document/src/create.ts b/packages/json-document-sheet-document/src/create.ts new file mode 100644 index 00000000..1b9171b8 --- /dev/null +++ b/packages/json-document-sheet-document/src/create.ts @@ -0,0 +1,38 @@ +import {assertSheetDocument,type SheetDocument,type SheetColumn,type SheetRow} from "./schema.js"; + +export interface CreateSheetDocumentOptions { + readonly rows?:number; + readonly columns?:number; + readonly name?:string; + readonly columnWidth?:number; + readonly rowHeight?:number; +} +/** Create the shared document shape; Hosts supply only policy values. */ +export function createSheetDocument(options:CreateSheetDocumentOptions={}):SheetDocument { + const rowCount=options.rows ?? 4,columnCount=options.columns ?? 3; + if(!Number.isSafeInteger(rowCount)||!Number.isSafeInteger(columnCount)||rowCount<0||columnCount<0)throw new RangeError("Sheet dimensions must be nonnegative integers."); + const columns=Array.from({length:columnCount},(_,index)=>({id:`column-${index+1}`,label:sheetColumnLabel(index),...(options.columnWidth === undefined ? {}:{width:options.columnWidth})})); + const document={...(options.name === undefined ? {}:{name:options.name}),columns,rows:Array.from({length:rowCount},(_,index)=>({id:`row-${index+1}`,...(options.rowHeight === undefined ? {}:{height:options.rowHeight}),cells:Object.fromEntries(columns.map(column=>[column.id,""]))}))}; + assertSheetDocument(document);return document; +} + +/** 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 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-sheet-document/src/index.ts b/packages/json-document-sheet-document/src/index.ts new file mode 100644 index 00000000..c8493e33 --- /dev/null +++ b/packages/json-document-sheet-document/src/index.ts @@ -0,0 +1,4 @@ +export {sheetColumnSchema,sheetRowSchema,sheetDocumentSchema,assertSheetDocument} from "./schema.js"; +export type {SheetColumn,SheetRow,SheetDocument} from "./schema.js"; +export {createSheetDocument,createSheetColumn,createSheetRow,sheetColumnLabel} from "./create.js"; +export type {CreateSheetDocumentOptions} from "./create.js"; diff --git a/packages/json-document-sheet-document/src/schema.ts b/packages/json-document-sheet-document/src/schema.ts new file mode 100644 index 00000000..88fe5e40 --- /dev/null +++ b/packages/json-document-sheet-document/src/schema.ts @@ -0,0 +1,31 @@ +import { isJSONValue, type JSONValue } from "@interactive-os/json-document"; +import { z } from "zod"; + +const jsonValue = z.custom(isJSONValue, "Expected a JSON value."); +/** Canonical column schema; JSON extensions remain compatible with existing documents. */ +export const sheetColumnSchema = z.object({id:z.string().min(1),label:z.string()}).catchall(jsonValue).superRefine((column,context)=>{ + if("width" in column && (typeof column.width !== "number" || !Number.isFinite(column.width) || column.width <= 0))context.addIssue({code:"custom",path:["width"],message:"Column width must be positive and finite."}); +}).readonly(); +/** Canonical row schema. Cell values are JSON, including null and structured values. */ +export const sheetRowSchema = z.object({id:z.string().min(1),cells:z.record(z.string(),jsonValue).readonly()}).catchall(jsonValue).superRefine((row,context)=>{ + if("height" in row && (typeof row.height !== "number" || !Number.isFinite(row.height) || row.height <= 0))context.addIssue({code:"custom",path:["height"],message:"Row height must be positive and finite."}); +}).readonly(); +/** One runtime schema for standalone, Markdown-projected and embedded sheets. */ +export const sheetDocumentSchema = z.object({columns:z.array(sheetColumnSchema).readonly(),rows:z.array(sheetRowSchema).readonly()}).catchall(jsonValue).superRefine((document,context)=>{ + for(const [axis,items] of [["columns",document.columns],["rows",document.rows]] as const){ + const ids=new Set(); + items.forEach((item,index)=>{ + if(ids.has(item.id))context.addIssue({code:"custom",path:[axis,index,"id"],message:"Sheet ids must be unique."}); + ids.add(item.id); + }); + } + document.rows.forEach((row,index)=>{for(const column of document.columns)if(!Object.hasOwn(row.cells,column.id))context.addIssue({code:"custom",path:["rows",index,"cells",column.id],message:"Sheet row is missing a column cell."});}); +}).readonly(); +export type SheetColumn = z.infer; +export type SheetRow = z.infer; +export type SheetDocument = z.infer; +/** Validate without replacing the caller's document identity. */ +export function assertSheetDocument(value:unknown):asserts value is SheetDocument { + if(!isJSONValue(value))throw new TypeError("Sheet requires a JSON document."); + sheetDocumentSchema.parse(value); +} diff --git a/packages/json-document-sheet-document/tests/schema.test.ts b/packages/json-document-sheet-document/tests/schema.test.ts new file mode 100644 index 00000000..b4c59222 --- /dev/null +++ b/packages/json-document-sheet-document/tests/schema.test.ts @@ -0,0 +1,31 @@ +import {expect,test} from "vitest"; +import {createSheetDocument,sheetDocumentSchema,assertSheetDocument} from "../src/index.js"; + +test("schema retains legacy IDs, JSON cells and extensions without adding a discriminator",()=>{ + const legacy={name:"기존 표",columns:[{id:"c0",label:"A",width:120}],rows:[{id:"r0",height:32,cells:{c0:{nested:[null,0,false]}}}]}; + expect(sheetDocumentSchema.parse(legacy)).toEqual(legacy);expect(()=>assertSheetDocument(legacy)).not.toThrow(); + const created=createSheetDocument({rows:2,columns:3});expect(sheetDocumentSchema.safeParse(created).success).toBe(true); + expect(created.rows[0]!.cells).not.toBe(created.rows[1]!.cells); +}); +test.each([ + {columns:[{id:"a",label:"A"},{id:"a",label:"B"}],rows:[]}, + {columns:[{id:"a",label:"A"}],rows:[{id:"r",cells:{}}]}, + {columns:[{id:"a",label:"A",width:0}],rows:[]}, + {columns:[],rows:[{id:"r",cells:{},height:-1}]}, + {columns:[{id:"",label:"A"}],rows:[]}, + {columns:[],rows:[{id:"r",cells:{}},{id:"r",cells:{}}]}, + {columns:[{id:"a",label:"A"}],rows:[{id:"r",cells:{a:undefined}}]}, +])("rejects invalid table structures %#",value=>expect(()=>assertSheetDocument(value)).toThrow()); +test("creation validates counts and sizes",()=>{ + expect(()=>createSheetDocument({rows:-1})).toThrow();expect(()=>createSheetDocument({columnWidth:NaN})).toThrow(); + expect(createSheetDocument({rows:0,columns:0})).toEqual({rows:[],columns:[]}); +}); + +test("partial row and column schemas enforce the same size constraints as a document",async()=>{ + const {sheetRowSchema,sheetColumnSchema}=await import("../src/index.js"); + for(const size of [-1,0,NaN,Infinity,"large"]){ + const column={id:"a",label:"A",width:size},row={id:"r",cells:{a:""},height:size}; + expect(sheetColumnSchema.safeParse(column).success).toBe(false);expect(sheetDocumentSchema.safeParse({columns:[column],rows:[]}).success).toBe(false); + expect(sheetRowSchema.safeParse(row).success).toBe(false);expect(sheetDocumentSchema.safeParse({columns:[{id:"a",label:"A"}],rows:[row]}).success).toBe(false); + } +}); diff --git a/packages/json-document-sheet-document/tsconfig.json b/packages/json-document-sheet-document/tsconfig.json new file mode 100644 index 00000000..fa193bf2 --- /dev/null +++ b/packages/json-document-sheet-document/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig/library.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, + "references": [{ "path": "../json-document" }], + "include": ["src/**/*.ts"] +} diff --git a/packages/json-document-sheet-document/tsconfig.test.json b/packages/json-document-sheet-document/tsconfig.test.json new file mode 100644 index 00000000..47af2477 --- /dev/null +++ b/packages/json-document-sheet-document/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "composite": false, "noEmit": true, "rootDir": "../..", "types": ["node", "vitest/globals"] }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/json-document-sheet-document/vitest.config.ts b/packages/json-document-sheet-document/vitest.config.ts new file mode 100644 index 00000000..c61c34d5 --- /dev/null +++ b/packages/json-document-sheet-document/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineNodeProject } from "../../test/vitest.shared.js"; + +export default defineNodeProject("json-document-sheet-document"); diff --git a/packages/json-document-sheet/docs/api-reference.md b/packages/json-document-sheet/docs/api-reference.md index 75b2bb2e..e7822544 100644 --- a/packages/json-document-sheet/docs/api-reference.md +++ b/packages/json-document-sheet/docs/api-reference.md @@ -12,6 +12,7 @@ Sheet와 Markdown 표의 셀 선택·편집·구조 조작 UI 조합의 public e interface SheetCellEditorProps { readonly label: string; readonly value: string; + readonly initialSelection?: "all" | "end"; readonly style: CSSProperties; readonly onValueChange: (value: string) => void; readonly onKeyDown: KeyboardEventHandler; @@ -21,7 +22,7 @@ interface SheetCellEditorProps { ## `SheetHand` ```ts -SheetHand({ editor, label, headerRow, coordinateHeaders, profile, renderCell, renderEditor, onExit }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +SheetHand({ editor, label, headerRow, coordinateHeaders, profile, renderCell, renderEditor, onExit, onDeactivate }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element ``` ## `SheetHandProps` @@ -33,8 +34,9 @@ interface SheetHandProps { readonly coordinateHeaders?: boolean; /** 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"; + /** Spreadsheet Enter starts editing on Mac; explicit policy objects override platform defaults. */ + readonly profile?: keyof typeof gridEditingProfiles | GridEditingProfile; + readonly onDeactivate?: () => void; 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. */ diff --git a/packages/json-document-sheet/docs/editing.md b/packages/json-document-sheet/docs/editing.md index bf0cd975..798ddb40 100644 --- a/packages/json-document-sheet/docs/editing.md +++ b/packages/json-document-sheet/docs/editing.md @@ -26,7 +26,7 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: ## 문서 표와 기존 Sheet의 입력 정책 -`profile="spreadsheet-grid"`가 기본값입니다. Enter/Shift+Enter는 세로 이동, F2/더블클릭은 편집 시작입니다. `profile="document-table"`에서는 Enter로 편집을 시작합니다. 편집 중 Enter/Tab은 확정 후 이동하며 사각 선택은 유지됩니다. Sheet의 Ctrl+Enter는 초안을 선택된 셀들에 한 번에 채웁니다. Shift+Space/Control+Space로 행/열을 선택할 수 있습니다. +`profile="spreadsheet-grid"`가 기본값입니다. Mac에서는 `spreadsheet-mac` 정책이 자동 적용되어 Enter로 편집을 시작합니다. 다른 플랫폼에서는 Enter/Shift+Enter는 세로 이동, F2/더블클릭은 편집 시작입니다. `profile="spreadsheet-mac"`으로 명시하거나 `GridEditingProfile` 객체로 정책을 고정할 수도 있습니다. `profile="document-table"`에서는 Enter로 편집을 시작합니다. 편집 중 Enter/Tab은 확정 후 이동하며 사각 선택은 유지됩니다. Sheet의 Ctrl+Enter는 초안을 선택된 셀들에 한 번에 채웁니다. Shift+Space/Control+Space로 행/열을 선택할 수 있습니다. 포인터 드래그, 행/열 헤더 선택, 채우기 핸들을 제공합니다. 채우기 핸들을 드래그하면 값 패턴을 반복하고 클릭 또는 키보드 활성화는 아래 한 행에 반복합니다. 수식 자동 보정 및 숫자 수열 생성은 이 Hand의 기본 채우기 계약에 포함되지 않습니다. @@ -35,3 +35,48 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: `renderEditor`는 포맷 소유 편집기를 받을 수 있습니다. Markdown 소비자는 `MarkdownCellEditor`를 연결하므로 편집 전후 서식도 유지합니다. 단순 문자열은 기존 Field를 사용합니다. `renderCell`은 읽기 표현이며, 맞는 포맷의 `renderEditor`와 함께 사용합니다. 독립 Sheet 애플리케이션은 `coordinateHeaders`를 설정해 저장된 필드 label 대신 현재 순서의 A/B/C 헤더를 표시합니다. 열 삽입/삭제 후에도 좌표가 연속됩니다. 기본값은 필드 label을 유지하므로 문서 표와 기존 예제의 의미를 보존합니다. 실제 조합은 `/applications/sheet`에서 확인합니다. + +`profile`에는 기본 이름 또는 Affordance의 `GridEditingProfile` 값을 전달할 수 있습니다. `SheetCellEditorProps.initialSelection`을 포맷 편집기가 소비하므로 직접 타이핑의 첫 글자가 전체 선택되어 다음 글자에 덮이지 않습니다. `onDeactivate`는 초안이 없는 Escape에서 호출됩니다. Canvas는 이를 바깥 객체 포커스에 연결하고 Bear는 기존 `onExit` 경계를 유지합니다. + +표 클립보드는 Web의 `sheetClipboardRepresentations`로 내부 JSON과 외부 텍스트를 함께 처리합니다. 행열 헤더 선택 후에는 활성 셀로 포커스를 넘겨 키보드 이동을 이어갑니다. 확대·축소된 DOM/SVG에 있을 때 리사이즈는 Web의 layout 좌표 변환을 소비합니다. + +## 공유 문서와 독립 View + +```live-demo +/demo/sheet-views +``` + +[두 View Usage](/demo/sheet-views)는 하나의 SheetEditor에서 `createView()`를 두 번 호출합니다. +값·변경·History는 원본 편집기가 소유하고, 선택·편집 draft·포커스·스크롤과 표시 순서는 +각 View/Hand에 남습니다. Host가 문서를 복제하거나 양방향 subscribe 동기화를 구현하지 않습니다. + +```ts +const source = createSheetEditor(document); +const left = source.createView(); +const right = source.createView({rowOrder: ["gamma", "beta", "alpha"], columnOrder: ["owner", "status", "name"]}); +// +// +``` + +Hand는 `editor.grid`의 행·열 순서를 렌더하고 모든 명령은 같은 topology를 사용합니다. +행·열 순서는 ID 목록이며 삭제된 ID는 빠지고 새 ID는 문서 순서로 뒤에 붙습니다. +필터·정렬 식·병합 셀·원격 참조 저장 형식은 이 계약에 포함하지 않습니다. + +## Sheet Hands 지원 조합 · RC 범위 + +| 책임 | 정본과 지원 계약 | +| --- | --- | +| Schema | Sheet Document의 공통 스키마, 행·열 ID, JSON 셀, 크기 검증 | +| Topology / Selection | Editing의 GridTopology와 ID 기반 직사각형 범위, View별 독립 선택 | +| Intent / Planning | SheetIntent와 planSheetIntent; 표시 순서에서 문서 ID로 적용 | +| Clipboard | primary 직사각형, 현재 View 순서, native JSON/외부 TSV | +| History | 원본 문서/부모 편집기가 한 번 기록; View 사이의 편집 그룹 분리 | +| Readonly | 선택·복사는 허용; 수정·붙여넣기·잘라내기·구조·Undo/Redo 차단 | +| Affordance | document-table의 Enter 편집, spreadsheet-grid의 Enter 이동 | +| Hand | 동일 SheetHand와 renderCell/renderEditor 슬롯; 편집 전후 형태 유지 | + +독립 Sheet, Markdown 표 편집기, Canvas 내장 표 편집기에서 같은 `createView` 계약을 +사용할 수 있습니다. 별도로 저장된 Markdown과 Canvas 문서가 자동으로 같은 데이터가 +되는 것은 아닙니다. Markdown의 행·열 ID는 현재 위치에서 투영되므로 영속 ID 참조 +프로파일을 제공한다는 의미가 아닙니다. Markdown 구조 변경을 가로질러 같은 논리 셀을 +추적하는 영속 ID 계약은 제공하지 않습니다. 전체 Official Hands SDK의 Stable 선언도 아닙니다. diff --git a/packages/json-document-sheet/src/sheet-axis-resize.tsx b/packages/json-document-sheet/src/sheet-axis-resize.tsx index 77cc77e2..7628779d 100644 --- a/packages/json-document-sheet/src/sheet-axis-resize.tsx +++ b/packages/json-document-sheet/src/sheet-axis-resize.tsx @@ -1,3 +1,4 @@ +import {projectWebClientDeltaToElement} from "@interactive-os/json-document-web"; import {useRef} from "react"; import {resizeValueForKey, storedResizeValue} from "@interactive-os/json-document-affordance"; import {useInteractionHandle} from "@interactive-os/json-document-ui-primitives-react"; @@ -11,15 +12,16 @@ export function SheetAxisResize({axis, label, onPreview, onCommit}: { 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 === "start") {const element = input.currentTarget.parentElement!; origin.current = axis === "x" ? element.offsetWidth : element.offsetHeight;} + const delta=projectWebClientDeltaToElement(input.currentTarget.parentElement!,event.delta); + const size = storedResizeValue(origin.current + (axis === "x" ? delta.dx : 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
event.stopPropagation()} onInput={event => event.stopPropagation()} onPointerDown={event => {event.stopPropagation();if (!draft) pointer.onPointerDown(event);}} onLostPointerCapture={pointer.onLostPointerCapture}> - 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())}> + 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())}>
- +
{sheet.columns.map(column => )})}{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?.key.rowId === row.id && draft.key.columnId === column.id; - const editorProps: SheetCellEditorProps | null = active ? {label:`${column.label} ${index + 1}행 편집`, value:draft.draft, + const editorProps: SheetCellEditorProps | null = active ? {label:`${column.label} ${index + 1}행 편집`, value:draft.draft,initialSelection:draft.initialSelection ?? "all", style:{position:"absolute",inset:0,boxSizing:"border-box",width:"100%",height:"100%",minWidth:0,margin:0,padding:"inherit",border:0,borderRadius:0,outline:"none",boxShadow:"none",background:"transparent",color:"inherit",font:"inherit",letterSpacing:"inherit",overflow:"auto"}, onValueChange:rename.session.update,onBlur:finish,onKeyDown:keyDown} : null; return {if (!pointer.consumeClick()) item.getPressHandler()(event);}} style={{minWidth: 90, padding: "7px 10px", border: "1px solid var(--border, #ddd)", position: "relative", outline:fillCells.has(gridPointKey(point)) ? "1px dashed var(--accent, #9f4937)" : undefined, fontWeight: headerRow && index === 0 ? 600 : undefined}} - onDoubleClick={() => rename.session.begin(point, jsonCellText(row.cells[column.id]))}> + onDoubleClick={() => beginEdit(point)}>
{(renderCell ? renderCell(jsonCellText(row.cells[column.id])) : jsonCellText(row.cells[column.id])) || }
- {editorProps && (renderEditor ? renderEditor(editorProps) : event.currentTarget.select()} onPointerDown={event => event.stopPropagation()} + {editorProps && (renderEditor ? renderEditor(editorProps) : {const input=event.currentTarget;if(editorProps.initialSelection === "end") input.setSelectionRange(input.value.length,input.value.length);else input.select();}} onPointerDown={event => event.stopPropagation()} />)} - {!draft && primaryRange && primaryBounds && row.id === topology.rowIds[primaryBounds.rowEnd] && column.id === topology.columnIds[primaryBounds.columnEnd] + {editable && !draft && primaryRange && primaryBounds && row.id === topology.rowIds[primaryBounds.rowEnd] && column.id === topology.columnIds[primaryBounds.columnEnd] && }
; })})}
{sheet.columns.map((column,columnIndex) => - - {editor.capabilities.resize && setColumnPreview(size === null ? null : {id:column.id,size})} onCommit={width => report(editor.dispatch({type:"column.resize",columnId:column.id,width}))} />} + + {editable && editor.capabilities.resize && setColumnPreview(size === null ? null : {id:column.id,size})} onCommit={width => report(editor.dispatch({type:"column.resize",columnId:column.id,width}))} />}
- - {editor.capabilities.resize && setRowPreview(size === null ? null : {id:row.id,size})} onCommit={height => report(editor.dispatch({type:"row.resize",rowId:row.id,height}))} />} + + {editable && editor.capabilities.resize && setRowPreview(size === null ? null : {id:row.id,size})} onCommit={height => report(editor.dispatch({type:"row.resize",rowId:row.id,height}))} />}
+ {available !== "ready" && {available === "readonly" ? "읽기 전용 표" : available === "missing" ? "표가 삭제되었습니다" : "표 데이터를 읽을 수 없습니다"}} {message && {message}}
; } diff --git a/packages/json-document-sheet/src/sheet-range-selection.tsx b/packages/json-document-sheet/src/sheet-range-selection.tsx index d98f877b..340523d1 100644 --- a/packages/json-document-sheet/src/sheet-range-selection.tsx +++ b/packages/json-document-sheet/src/sheet-range-selection.tsx @@ -1,6 +1,6 @@ import {useRef, type RefObject} from "react"; import type {GridPoint, SheetEditor} from "@interactive-os/json-document-editing"; -import {hitTestWebGrid, findWebGridCell} from "@interactive-os/json-document-web"; +import {hitTestWebGrid, findWebGridCell,selectionOperationFromModifiers} from "@interactive-os/json-document-web"; import {useInteractionHandle} from "@interactive-os/json-document-ui-primitives-react"; import {idlePointerInteraction, reducePressInteraction, type PointerInteractionState} from "@interactive-os/json-document-selection"; @@ -20,7 +20,7 @@ export function useSheetRangeSelection(editor: SheetEditor, surface: RefObject(),{phase:"start",point,pointerId:String(input.pointerId),operation}).state; editor.dispatch({type:"selection.set",...point,mode:operation}); findWebGridCell(surface.current, point)?.focus({preventScroll:true}); diff --git a/packages/json-document-sheet/tests/sheet-hand.test.tsx b/packages/json-document-sheet/tests/sheet-hand.test.tsx index 182c63f7..7ae16e7c 100644 --- a/packages/json-document-sheet/tests/sheet-hand.test.tsx +++ b/packages/json-document-sheet/tests/sheet-hand.test.tsx @@ -1,8 +1,8 @@ -import { afterEach, expect, test } from "vitest"; +import { afterEach, expect, test, vi } 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); +afterEach(()=>{cleanup();vi.restoreAllMocks();}); 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(); @@ -40,3 +40,30 @@ test("a rejected commit retains the draft and does not navigate or close editing expect(screen.queryByRole("textbox")).toBeNull(); expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("retained"); }); + +test("readonly View exposes current values and blocks toolbar and keyboard history",()=>{ + const owner=createSheetEditor({columns:[{id:"a",label:"A"}],rows:[{id:"r",cells:{a:"one"}}]}); + owner.dispatch({type:"cell.commit",rowId:"r",columnId:"a",value:"saved"}); + const view=owner.createView({readOnly:()=>true}),before=owner.snapshot.value; + render(); + expect((screen.getByRole("button",{name:"실행 취소"}) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button",{name:"행 추가"}) as HTMLButtonElement).disabled).toBe(true); + fireEvent.doubleClick(screen.getByText("saved"));expect(screen.queryByRole("textbox")).toBeNull(); + fireEvent.keyDown(screen.getByRole("gridcell"),{key:"z",ctrlKey:true}); + expect(owner.snapshot.value).toBe(before); +}); + + test.each(["MacIntel", "Win32"])("spreadsheet Enter follows %s keyboard policy", platform=>{ + vi.spyOn(window.navigator,"platform","get").mockReturnValue(platform); + const editor=createSheetEditor({columns:[{id:"a",label:"A"}],rows:[{id:"1",cells:{a:"one"}},{id:"2",cells:{a:"two"}}]}); + render(); + fireEvent.keyDown(screen.getAllByRole("gridcell")[0]!,{key:"Enter"}); + if(platform === "MacIntel") { + expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("one"); + fireEvent.change(screen.getByRole("textbox"),{target:{value:"edited"}}); + fireEvent.keyDown(screen.getByRole("textbox"),{key:"Enter"}); + expect((editor.snapshot.value as SheetDocument).rows[0]!.cells.a).toBe("edited"); + } + expect(screen.queryByRole("textbox")).toBeNull(); + expect(editor.snapshot.selection.focus?.rowId).toBe("2"); + }); diff --git a/packages/json-document-web/docs/api-reference.md b/packages/json-document-web/docs/api-reference.md index 948a1d04..4d19dd68 100644 --- a/packages/json-document-web/docs/api-reference.md +++ b/packages/json-document-web/docs/api-reference.md @@ -234,6 +234,11 @@ parseWebHTMLFragment(html: string): WebHTMLFragment | null ```ts pressInteractionFromWeb(input: WebPressInput): WebPressInteraction | null ``` +## `projectWebClientDeltaToElement` + +```ts +projectWebClientDeltaToElement(element: { readonly offsetWidth: number; readonly offsetHeight: number; getBoundingClientRect(): { readonly width: number; readonly height: number; }; }, delta: { readonly dx: number; readonly dy: number; }): { dx: number; dy: number; } +``` ## `projectWebClientPointToSVG` ```ts @@ -289,6 +294,11 @@ selectionOperationFromModifiers(modifiers: WebModifierState): Extract ``` +## `sheetClipboardRepresentations` + +```ts +const sheetClipboardRepresentations: readonly WebClipboardRepresentation[] +``` ## `textClipboardCodec` ```ts @@ -757,6 +767,11 @@ type WebKeyboardCommand = | { readonly type: "undo" } | { readonly type: "redo" }; ``` +## `webKeyboardPlatform` + +```ts +webKeyboardPlatform(environment?: { readonly platform: string; readonly maxTouchPoints?: number; } | undefined): "mac" | "standard" +``` ## `WebKeyboardStroke` ```ts diff --git a/packages/json-document-web/docs/grid-embedding.md b/packages/json-document-web/docs/grid-embedding.md new file mode 100644 index 00000000..3e1f63b5 --- /dev/null +++ b/packages/json-document-web/docs/grid-embedding.md @@ -0,0 +1,5 @@ +# 내장 표의 좌표와 클립보드 + +표 상호작용을 다른 화면 안에 배치할 때 `projectWebClientDeltaToElement`가 화면 delta를 요소의 layout 좌표로 바꿉니다. CSS scale과 SVG foreignObject viewport 배율을 포함하며 회전/skew는 대상이 아닙니다. `sheetClipboardRepresentations`는 내부 Sheet JSON을 우선 읽고 외부 `text/plain`을 Editing의 표 텍스트 parser로 전달합니다. 두 API는 SheetHand와 Canvas 표 Usage에서 소비합니다. + +`sheetClipboardRepresentations`를 `createWebClipboardSurface`에 넘기면 내부 JSON을 우선 읽고, 일반 텍스트를 Sheet 행렬로 변환합니다. 화면이 작아져도 문서에 저장하는 행열 크기는 바뀌지 않도록 리사이즈 시작값은 layout 크기, 포인터 이동값은 변환한 delta를 사용합니다. diff --git a/packages/json-document-web/docs/text-keys.md b/packages/json-document-web/docs/text-keys.md index ba5f0534..6eddf661 100644 --- a/packages/json-document-web/docs/text-keys.md +++ b/packages/json-document-web/docs/text-keys.md @@ -3,3 +3,5 @@ `isWebComposingKey(event)`는 `isComposing` 및 keyCode 229를 native IME 입력으로 구분합니다. `webKeyboardText(stroke)`는 shortcut과 제어 키를 제외한 문자 payload를 반환합니다. 편집 시작·확정 의미는 Affordance가 결정합니다. `moveGridPoint(topology, point, 'previous' | 'next')`는 기존 API로 행을 넘는 순차 셀 이동을 지원합니다. 끝에서는 null을 반환하며, 소비자가 문서 밖 이동이나 기본 Tab 흐름을 연결합니다. [Sheet Usage](/demo/sheet)는 별도의 좌표 계산 없이 이 계약을 사용합니다. + +`webKeyboardPlatform()`은 브라우저의 키보드 플랫폼을 `mac` 또는 `standard`로 판별합니다. 환경 정보를 인자로 주입할 수 있으며 SSR·알 수 없는 환경·Mac으로 보고되는 터치 iPad는 `standard`입니다. [Sheet Views Usage](/demo/sheet-views)의 SheetHand가 이 API로 Mac 입력 프로파일을 선택합니다. diff --git a/packages/json-document-web/src/clipboard.ts b/packages/json-document-web/src/clipboard.ts index 4609155e..32feb49e 100644 --- a/packages/json-document-web/src/clipboard.ts +++ b/packages/json-document-web/src/clipboard.ts @@ -1,3 +1,4 @@ +import {parseSheetClipboardText, type SheetClipboard} from "@interactive-os/json-document-editing"; import { routeWebClipboardEvent } from "./clipboard-event.js"; import { databaseClipboardFormat, @@ -178,6 +179,12 @@ export const documentClipboardCodec = createWebJSONClipboardRepresentation(docum export const sheetClipboardCodec = createWebJSONClipboardRepresentation(sheetClipboardFormat); +/** Prefer the lossless native payload and accept external spreadsheet/plain text as fallback. */ +export const sheetClipboardRepresentations: ReadonlyArray> = [ + sheetClipboardCodec, + {mimeType:"text/plain",encode:payload=>payload.text,decode:parseSheetClipboardText}, +]; + export const orderClipboardCodec = createWebJSONClipboardRepresentation(orderClipboardFormat); export const objectClipboardCodec = createWebJSONClipboardRepresentation(objectClipboardFormat); diff --git a/packages/json-document-web/src/element-coordinate.ts b/packages/json-document-web/src/element-coordinate.ts new file mode 100644 index 00000000..40224971 --- /dev/null +++ b/packages/json-document-web/src/element-coordinate.ts @@ -0,0 +1,11 @@ +/** Converts client-pixel movement into an axis-aligned HTML element's untransformed layout units. + * Includes CSS scale and an enclosing SVG foreignObject's viewport scale; rotation/skew are not supported. + */ +export function projectWebClientDeltaToElement(element: { + readonly offsetWidth: number; readonly offsetHeight: number; + getBoundingClientRect(): {readonly width: number; readonly height: number}; +}, delta: {readonly dx: number; readonly dy: number}): {dx: number; dy: number} { + const rect=element.getBoundingClientRect(); + return {dx:rect.width > 0 ? delta.dx * element.offsetWidth / rect.width : 0, + dy:rect.height > 0 ? delta.dy * element.offsetHeight / rect.height : 0}; +} diff --git a/packages/json-document-web/src/index.ts b/packages/json-document-web/src/index.ts index 18b08cd7..9bb6d6ed 100644 --- a/packages/json-document-web/src/index.ts +++ b/packages/json-document-web/src/index.ts @@ -11,6 +11,7 @@ export { objectClipboardCodec, orderClipboardCodec, sheetClipboardCodec, + sheetClipboardRepresentations, treeClipboardCodec, } from "./clipboard.js"; export { selectionOperationFromModifiers } from "./modifiers.js"; @@ -150,3 +151,6 @@ export type { export { textClipboardCodec } from "./clipboard.js"; export { hitTestWebGrid } from "./grid-cell.js"; export {createWebStoredDocument, type WebStoredDocument, type WebStoredDocumentOptions, type WebStoredDocumentSource, type WebDocumentSaveState} from "./stored-document.js"; +export {projectWebClientDeltaToElement} from "./element-coordinate.js"; + +export {webKeyboardPlatform} from "./keyboard.js"; diff --git a/packages/json-document-web/src/keyboard.ts b/packages/json-document-web/src/keyboard.ts index 0b562149..9e69382a 100644 --- a/packages/json-document-web/src/keyboard.ts +++ b/packages/json-document-web/src/keyboard.ts @@ -163,3 +163,8 @@ export function isWebComposingKey(event: {readonly isComposing?: boolean; readon 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; } + +/** Resolve keyboard platform from the owning window; SSR and unknown platforms use standard keys. */ +export function webKeyboardPlatform(environment: {readonly platform:string; readonly maxTouchPoints?:number} | undefined = typeof navigator === "undefined" ? undefined : navigator): "mac" | "standard" { + return environment && /^Mac/.test(environment.platform) && !((environment.maxTouchPoints ?? 0) > 1) ? "mac" : "standard"; +} diff --git a/packages/json-document-web/tests/element-coordinate.test.ts b/packages/json-document-web/tests/element-coordinate.test.ts new file mode 100644 index 00000000..0ae38cd4 --- /dev/null +++ b/packages/json-document-web/tests/element-coordinate.test.ts @@ -0,0 +1,5 @@ +import {expect,test} from "vitest"; +import {projectWebClientDeltaToElement} from "../src/index.js"; +test("client movement is converted independently on both scaled axes",()=>{ + expect(projectWebClientDeltaToElement({offsetWidth:120,offsetHeight:40,getBoundingClientRect:()=>({width:60,height:80})},{dx:10,dy:10})).toEqual({dx:20,dy:5}); +}); diff --git a/site/package.json b/site/package.json index 7c5e5e38..0b4ab7bc 100644 --- a/site/package.json +++ b/site/package.json @@ -117,7 +117,8 @@ "tailwindcss": "^3.4.17", "typescript": "^5.0.0", "vite": "^6.0.0", - "vitest": "^4.1.7" + "vitest": "^4.1.7", + "@interactive-os/json-document-sheet-document": "*" }, "optionalDependencies": { "@esbuild/linux-x64": "0.25.12" diff --git a/site/scripts/check-canonical-module-closure.mjs b/site/scripts/check-canonical-module-closure.mjs index b47d05c0..9482f9d4 100644 --- a/site/scripts/check-canonical-module-closure.mjs +++ b/site/scripts/check-canonical-module-closure.mjs @@ -15,6 +15,8 @@ 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-affordance", "resolveGridEditActivation"], + ["@interactive-os/json-document-web", "sheetClipboardRepresentations"], ["@interactive-os/json-document-web", "moveGridPoint"], ["@interactive-os/json-document-ui-primitives-react", "Field"], ]) { @@ -28,6 +30,20 @@ if (existsSync(join(repositoryRoot, "packages/json-document-markdown-react/src/m const markdownTableEditor = readSource("packages/json-document-editing/src/markdown-table.ts"); if (/from\s+["'](?:react|react-dom)(?:\/[^"']*)?["']/.test(markdownTableEditor)) throw new Error("Markdown source editor must stay framework independent"); +// Embedded tables share the projection lifecycle and the exact same cell surface. +for (const path of ["packages/json-document-editing/src/markdown-table.ts", "packages/json-document-editing/src/object-sheet.ts"]) { + const source = readSource(path); + if (!hasNamedImport(source, "./projected-sheet.js", "createProjectedSheetEditor") || /new Set|let unsubscribe/.test(source)) throw new Error(`${path} bypasses the projected Sheet lifecycle`); +} +if (!hasNamedImport(readSource("packages/json-document-canvas/src/canvas-sheet-object.tsx"), "@interactive-os/json-document-sheet", "SheetHand")) throw new Error("Canvas must consume SheetHand"); +if (!hasNamedImport(readSource("packages/json-document-sheet/src/sheet-axis-resize.tsx"), "@interactive-os/json-document-web", "projectWebClientDeltaToElement")) throw new Error("Sheet resize must project client coordinates"); + +// Modifier meaning belongs to Web even when the consumer is already a shared package. +for (const path of ["packages/json-document-react/src/use-editing.ts", "packages/json-document-sheet/src/sheet-range-selection.tsx"]) { + const source=readSource(path); + if (!hasNamedImport(source,"@interactive-os/json-document-web","selectionOperationFromModifiers") || /(?:event|input)\.shiftKey\s*\?\s*"extend"/.test(source)) throw new Error(`${path} duplicates Web selection modifier translation`); +} + const annotationDemo = readSource("routes/annotation-demo/AnnotationDemoRoute.tsx"); for (const symbol of ["AnnotationHand", "useAnnotationOutput"]) { if (!hasNamedImport(annotationDemo, "@interactive-os/json-document-annotation", symbol)) throw new Error(`Annotation Demo must consume the canonical ${symbol}`); diff --git a/site/scripts/check-live-demo-primitives.mjs b/site/scripts/check-live-demo-primitives.mjs index 4dc7acf4..15749488 100644 --- a/site/scripts/check-live-demo-primitives.mjs +++ b/site/scripts/check-live-demo-primitives.mjs @@ -9,8 +9,9 @@ const liveDemoPaths = [...registry.matchAll(/^\s*"([^"]+)": demo\(/gm)].map((mat // /connectors/a2ui uses Command for stream-fixture input and the canonical Inspector. // /demo/markdown-caret uses MarkdownEditingSurface and the canonical JsonInspector. -if (liveDemoPaths.length !== 42) { - throw new Error(`Live Demo 감사 집합이 42개에서 ${liveDemoPaths.length}개로 바뀌었습니다. 새 경로의 control 분류를 이 guard와 함께 갱신하세요.`); +// /demo/sheet-views composes two canonical SheetHands; it adds no route-local controls. +if (liveDemoPaths.length !== 43) { + throw new Error(`Live Demo 감사 집합이 43개에서 ${liveDemoPaths.length}개로 바뀌었습니다. 새 경로의 control 분류를 이 guard와 함께 갱신하세요.`); } const roots = [join(sourceRoot, "routes"), join(sourceRoot, "shared/composer"), join(sourceRoot, "shared/demo-workbench"), join(sourceRoot, "shared/ui")]; diff --git a/site/site-routes.json b/site/site-routes.json index 2750f814..7821f2b9 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -285,7 +285,8 @@ "packages/json-document-editing/docs/object-selection.md", "packages/json-document-editing/docs/calendar-profile.md", "packages/json-document-editing/docs/text.md", - "packages/json-document-editing/docs/sheet.md" + "packages/json-document-editing/docs/sheet.md", + "packages/json-document-editing/docs/sheet-contract.md" ], "module": { "packageName": "@interactive-os/json-document-editing", @@ -293,7 +294,9 @@ "/demo/history", "/demo/clipboard", "/demo/markdown-caret", - "/applications/sheet" + "/applications/sheet", + "/demo/canvas", + "/demo/sheet-views" ] }, "parentPath": "/docs/editing" @@ -419,7 +422,8 @@ "packages/json-document-web/docs/text-clipboard.md", "packages/json-document-web/docs/interaction-recording.md", "packages/json-document-web/docs/text-keys.md", - "packages/json-document-web/docs/stored-document.md" + "packages/json-document-web/docs/stored-document.md", + "packages/json-document-web/docs/grid-embedding.md" ], "module": { "packageName": "@interactive-os/json-document-web", @@ -427,7 +431,8 @@ "/adapters/keyboard", "/adapters/clipboard", "/demo/viewport", - "/applications/sheet" + "/applications/sheet", + "/demo/canvas" ] }, "parentPath": "/docs/adapters" @@ -592,7 +597,8 @@ "packageName": "@interactive-os/json-document-affordance", "usagePaths": [ "/affordances/handles", - "/demo/viewport" + "/demo/viewport", + "/demo/canvas" ] }, "parentPath": "/docs/affordance" @@ -760,7 +766,8 @@ "description": "Canvas 입력·preview·UI 조합의 API와 Usage입니다.", "language": "ko", "documentIncludes": [ - "packages/json-document-canvas/docs/api.md" + "packages/json-document-canvas/docs/api.md", + "packages/json-document-canvas/docs/embedded-sheet.md" ], "module": { "packageName": "@interactive-os/json-document-canvas", @@ -1148,6 +1155,14 @@ "navigationGroup": "Hands", "sidebar": false }, + { + "path": "/demo/sheet-views", + "label": "Sheet Views", + "title": "Shared Sheet Views - json-document", + "description": "같은 표 문서를 독립적인 선택과 표시 순서로 편집하는 두 View입니다.", + "navigationGroup": "Hands", + "sidebar": false + }, { "path": "/docs/tree", "label": "Tree", @@ -2029,11 +2044,31 @@ "packageName": "@interactive-os/json-document-sheet", "usagePaths": [ "/demo/sheet", - "/applications/sheet" + "/applications/sheet", + "/demo/canvas", + "/demo/sheet-views" ] }, "documentIncludes": [ "packages/json-document-sheet/docs/editing.md" ] + }, + { + "path": "/docs/api/sheet-document", + "label": "API · Sheet Document", + "title": "Sheet Document API - json-document", + "description": "공통 표 스키마·타입·검증·생성 계약입니다.", + "language": "ko", + "documentIncludes": [ + "packages/json-document-sheet-document/docs/api.md" + ], + "module": { + "packageName": "@interactive-os/json-document-sheet-document", + "usagePaths": [ + "/applications/sheet", + "/demo/sheet-views" + ] + }, + "parentPath": "/docs/document-types" } ] diff --git a/site/src/app/live-demo-registry.tsx b/site/src/app/live-demo-registry.tsx index 98761433..ab91eb88 100644 --- a/site/src/app/live-demo-registry.tsx +++ b/site/src/app/live-demo-registry.tsx @@ -20,6 +20,7 @@ const liveDemos: Readonly> = { "/demo/order": demo(() => import("../routes/order-demo/OrderDemoRoute"), "OrderDemoRoute", "routes/order-demo/OrderDemoRoute.tsx"), "/demo/object": demo(() => import("../routes/object-demo/ObjectDemoRoute"), "ObjectDemoRoute", "routes/object-demo/ObjectDemoRoute.tsx"), "/demo/canvas": demo(() => import("../routes/canvas-demo/CanvasDemoRoute"), "CanvasDemoRoute", "routes/canvas-demo/CanvasDemoRoute.tsx"), + "/demo/sheet-views": demo(() => import("../routes/sheet-demo/SheetViewsDemoRoute"), "SheetViewsDemoRoute", "routes/sheet-demo/SheetViewsDemoRoute.tsx"), "/demo/sheet": demo(() => import("../routes/sheet-demo/SheetDemoRoute"), "SheetDemoRoute", "routes/sheet-demo/SheetDemoRoute.tsx"), "/demo/tree": demo(() => import("../routes/tree-demo/TreeDemoRoute"), "TreeDemoRoute", "routes/tree-demo/TreeDemoRoute.tsx"), "/demo/kanban": demo(() => import("../routes/kanban-demo/KanbanDemoRoute"), "KanbanDemoRoute", "routes/kanban-demo/KanbanDemoRoute.tsx"), diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index 22328c71..99962825 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -51,6 +51,7 @@ import { Route as PageDemoObjectRouteImport } from "./routes/_page/demo/object"; import { Route as PageDemoOrderRouteImport } from "./routes/_page/demo/order"; import { Route as PageDemoSelectionRouteImport } from "./routes/_page/demo/selection"; import { Route as PageDemoSheetRouteImport } from "./routes/_page/demo/sheet"; +import { Route as PageDemoSheetViewsRouteImport } from "./routes/_page/demo/sheet-views"; import { Route as PageDemoTopologyRouteImport } from "./routes/_page/demo/topology"; import { Route as PageDemoTreeRouteImport } from "./routes/_page/demo/tree"; import { Route as PageDemoUiPrimitivesRouteImport } from "./routes/_page/demo/ui-primitives"; @@ -170,6 +171,7 @@ import { Route as PageDocsApiRichTextSuggestionReactRouteImport } from "./routes 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 PageDocsApiSheetDocumentRouteImport } from "./routes/_page/docs/api/sheet-document"; 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"; @@ -398,6 +400,11 @@ const PageDemoSheetRoute = PageDemoSheetRouteImport.update({ path: "/demo/sheet", getParentRoute: () => PageRoute, } as any); +const PageDemoSheetViewsRoute = PageDemoSheetViewsRouteImport.update({ + id: "/demo/sheet-views", + path: "/demo/sheet-views", + getParentRoute: () => PageRoute, +} as any); const PageDemoTopologyRoute = PageDemoTopologyRouteImport.update({ id: "/demo/topology", path: "/demo/topology", @@ -1032,6 +1039,12 @@ const PageDocsApiSheetRoute = PageDocsApiSheetRouteImport.update({ path: "/docs/api/sheet", getParentRoute: () => PageRoute, } as any); +const PageDocsApiSheetDocumentRoute = + PageDocsApiSheetDocumentRouteImport.update({ + id: "/docs/api/sheet-document", + path: "/docs/api/sheet-document", + getParentRoute: () => PageRoute, + } as any); const PageDocsApiTanstackTableRoute = PageDocsApiTanstackTableRouteImport.update({ id: "/docs/api/tanstack-table", @@ -1141,6 +1154,7 @@ export interface FileRoutesByFullPath { "/demo/order": typeof PageDemoOrderRoute; "/demo/selection": typeof PageDemoSelectionRoute; "/demo/sheet": typeof PageDemoSheetRoute; + "/demo/sheet-views": typeof PageDemoSheetViewsRoute; "/demo/topology": typeof PageDemoTopologyRoute; "/demo/tree": typeof PageDemoTreeRoute; "/demo/ui-primitives": typeof PageDemoUiPrimitivesRoute; @@ -1261,6 +1275,7 @@ export interface FileRoutesByFullPath { "/docs/api/rich-text-web": typeof PageDocsApiRichTextWebRoute; "/docs/api/selection": typeof PageDocsApiSelectionRoute; "/docs/api/sheet": typeof PageDocsApiSheetRoute; + "/docs/api/sheet-document": typeof PageDocsApiSheetDocumentRoute; "/docs/api/tanstack-table": typeof PageDocsApiTanstackTableRoute; "/docs/api/ui-primitives-react": typeof PageDocsApiUiPrimitivesReactRoute; "/docs/api/web": typeof PageDocsApiWebRoute; @@ -1315,6 +1330,7 @@ export interface FileRoutesByTo { "/demo/order": typeof PageDemoOrderRoute; "/demo/selection": typeof PageDemoSelectionRoute; "/demo/sheet": typeof PageDemoSheetRoute; + "/demo/sheet-views": typeof PageDemoSheetViewsRoute; "/demo/topology": typeof PageDemoTopologyRoute; "/demo/tree": typeof PageDemoTreeRoute; "/demo/ui-primitives": typeof PageDemoUiPrimitivesRoute; @@ -1435,6 +1451,7 @@ export interface FileRoutesByTo { "/docs/api/rich-text-web": typeof PageDocsApiRichTextWebRoute; "/docs/api/selection": typeof PageDocsApiSelectionRoute; "/docs/api/sheet": typeof PageDocsApiSheetRoute; + "/docs/api/sheet-document": typeof PageDocsApiSheetDocumentRoute; "/docs/api/tanstack-table": typeof PageDocsApiTanstackTableRoute; "/docs/api/ui-primitives-react": typeof PageDocsApiUiPrimitivesReactRoute; "/docs/api/web": typeof PageDocsApiWebRoute; @@ -1491,6 +1508,7 @@ export interface FileRoutesById { "/_page/demo/order": typeof PageDemoOrderRoute; "/_page/demo/selection": typeof PageDemoSelectionRoute; "/_page/demo/sheet": typeof PageDemoSheetRoute; + "/_page/demo/sheet-views": typeof PageDemoSheetViewsRoute; "/_page/demo/topology": typeof PageDemoTopologyRoute; "/_page/demo/tree": typeof PageDemoTreeRoute; "/_page/demo/ui-primitives": typeof PageDemoUiPrimitivesRoute; @@ -1611,6 +1629,7 @@ export interface FileRoutesById { "/_page/docs/api/rich-text-web": typeof PageDocsApiRichTextWebRoute; "/_page/docs/api/selection": typeof PageDocsApiSelectionRoute; "/_page/docs/api/sheet": typeof PageDocsApiSheetRoute; + "/_page/docs/api/sheet-document": typeof PageDocsApiSheetDocumentRoute; "/_page/docs/api/tanstack-table": typeof PageDocsApiTanstackTableRoute; "/_page/docs/api/ui-primitives-react": typeof PageDocsApiUiPrimitivesReactRoute; "/_page/docs/api/web": typeof PageDocsApiWebRoute; @@ -1667,6 +1686,7 @@ export interface FileRouteTypes { | "/demo/order" | "/demo/selection" | "/demo/sheet" + | "/demo/sheet-views" | "/demo/topology" | "/demo/tree" | "/demo/ui-primitives" @@ -1787,6 +1807,7 @@ export interface FileRouteTypes { | "/docs/api/rich-text-web" | "/docs/api/selection" | "/docs/api/sheet" + | "/docs/api/sheet-document" | "/docs/api/tanstack-table" | "/docs/api/ui-primitives-react" | "/docs/api/web" @@ -1841,6 +1862,7 @@ export interface FileRouteTypes { | "/demo/order" | "/demo/selection" | "/demo/sheet" + | "/demo/sheet-views" | "/demo/topology" | "/demo/tree" | "/demo/ui-primitives" @@ -1961,6 +1983,7 @@ export interface FileRouteTypes { | "/docs/api/rich-text-web" | "/docs/api/selection" | "/docs/api/sheet" + | "/docs/api/sheet-document" | "/docs/api/tanstack-table" | "/docs/api/ui-primitives-react" | "/docs/api/web" @@ -2016,6 +2039,7 @@ export interface FileRouteTypes { | "/_page/demo/order" | "/_page/demo/selection" | "/_page/demo/sheet" + | "/_page/demo/sheet-views" | "/_page/demo/topology" | "/_page/demo/tree" | "/_page/demo/ui-primitives" @@ -2136,6 +2160,7 @@ export interface FileRouteTypes { | "/_page/docs/api/rich-text-web" | "/_page/docs/api/selection" | "/_page/docs/api/sheet" + | "/_page/docs/api/sheet-document" | "/_page/docs/api/tanstack-table" | "/_page/docs/api/ui-primitives-react" | "/_page/docs/api/web" @@ -2456,6 +2481,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDemoSheetRouteImport; parentRoute: typeof PageRoute; }; + "/_page/demo/sheet-views": { + id: "/_page/demo/sheet-views"; + path: "/demo/sheet-views"; + fullPath: "/demo/sheet-views"; + preLoaderRoute: typeof PageDemoSheetViewsRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/demo/topology": { id: "/_page/demo/topology"; path: "/demo/topology"; @@ -3289,6 +3321,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsApiSheetRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/api/sheet-document": { + id: "/_page/docs/api/sheet-document"; + path: "/docs/api/sheet-document"; + fullPath: "/docs/api/sheet-document"; + preLoaderRoute: typeof PageDocsApiSheetDocumentRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/api/tanstack-table": { id: "/_page/docs/api/tanstack-table"; path: "/docs/api/tanstack-table"; @@ -3411,6 +3450,7 @@ interface PageRouteChildren { PageDemoOrderRoute: typeof PageDemoOrderRoute; PageDemoSelectionRoute: typeof PageDemoSelectionRoute; PageDemoSheetRoute: typeof PageDemoSheetRoute; + PageDemoSheetViewsRoute: typeof PageDemoSheetViewsRoute; PageDemoTopologyRoute: typeof PageDemoTopologyRoute; PageDemoTreeRoute: typeof PageDemoTreeRoute; PageDemoUiPrimitivesRoute: typeof PageDemoUiPrimitivesRoute; @@ -3531,6 +3571,7 @@ interface PageRouteChildren { PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; PageDocsApiSheetRoute: typeof PageDocsApiSheetRoute; + PageDocsApiSheetDocumentRoute: typeof PageDocsApiSheetDocumentRoute; PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; PageDocsApiWebRoute: typeof PageDocsApiWebRoute; @@ -3583,6 +3624,7 @@ const PageRouteChildren: PageRouteChildren = { PageDemoOrderRoute: PageDemoOrderRoute, PageDemoSelectionRoute: PageDemoSelectionRoute, PageDemoSheetRoute: PageDemoSheetRoute, + PageDemoSheetViewsRoute: PageDemoSheetViewsRoute, PageDemoTopologyRoute: PageDemoTopologyRoute, PageDemoTreeRoute: PageDemoTreeRoute, PageDemoUiPrimitivesRoute: PageDemoUiPrimitivesRoute, @@ -3705,6 +3747,7 @@ const PageRouteChildren: PageRouteChildren = { PageDocsApiRichTextWebRoute: PageDocsApiRichTextWebRoute, PageDocsApiSelectionRoute: PageDocsApiSelectionRoute, PageDocsApiSheetRoute: PageDocsApiSheetRoute, + PageDocsApiSheetDocumentRoute: PageDocsApiSheetDocumentRoute, PageDocsApiTanstackTableRoute: PageDocsApiTanstackTableRoute, PageDocsApiUiPrimitivesReactRoute: PageDocsApiUiPrimitivesReactRoute, PageDocsApiWebRoute: PageDocsApiWebRoute, diff --git a/site/src/app/routes/_page/demo/sheet-views.tsx b/site/src/app/routes/_page/demo/sheet-views.tsx new file mode 100644 index 00000000..58d91c01 --- /dev/null +++ b/site/src/app/routes/_page/demo/sheet-views.tsx @@ -0,0 +1,4 @@ +import {createFileRoute} from "@tanstack/react-router"; +import {defineDemo} from "../../../../shared/demo-workbench/define-demo"; +import {SheetViewsDemoRoute} from "../../../../routes/sheet-demo/SheetViewsDemoRoute"; +export const Route=createFileRoute("/_page/demo/sheet-views")({component:SheetViewsDemoRoute,...defineDemo({source:"routes/sheet-demo/SheetViewsDemoRoute.tsx"})}); diff --git a/site/src/app/routes/_page/docs/api/sheet-document.tsx b/site/src/app/routes/_page/docs/api/sheet-document.tsx new file mode 100644 index 00000000..59b84344 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/sheet-document.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-document")({ + component: function PackageApiReferenceRoute() { + return ; + }, +}); diff --git a/site/src/applications/sheet/SheetApplication.tsx b/site/src/applications/sheet/SheetApplication.tsx index d6fc59ad..b8129a35 100644 --- a/site/src/applications/sheet/SheetApplication.tsx +++ b/site/src/applications/sheet/SheetApplication.tsx @@ -1,5 +1,6 @@ +import {createSheetDocument} from "@interactive-os/json-document-sheet-document"; import {useEffect, useState, useSyncExternalStore} from "react"; -import {createSheetEditor, sheetColumnLabel, sheetSelectionSummary, type SheetDocument} from "@interactive-os/json-document-editing"; +import {createSheetEditor, sheetSelectionSummary, type SheetDocument} from "@interactive-os/json-document-editing"; import {useEditingSnapshot} from "@interactive-os/json-document-react"; import {createWebStoredDocument} from "@interactive-os/json-document-web"; import {SheetHand} from "@interactive-os/json-document-sheet"; @@ -7,14 +8,9 @@ import {Command, Field} from "@interactive-os/json-document-ui-primitives-react" import {Save} from "lucide-react"; import "./sheet-application.css"; -function blankSheet(): SheetDocument { - const columns = Array.from({length:12}, (_,index) => ({id:`column-${index + 1}`,label:sheetColumnLabel(index),width:120})); - return {name:"제목 없는 시트",columns,rows:Array.from({length:40},(_,index) => ({id:`row-${index + 1}`,height:32,cells:Object.fromEntries(columns.map(column => [column.id,""]))}))}; -} - export function SheetApplication() { const [stored] = useState(() => createWebStoredDocument({key:"json-document.sheet.v1",storage:() => window.localStorage, - create:() => createSheetEditor(blankSheet(),{structure:{minimumRows:1,minimumColumns:1}}), + create:() => createSheetEditor(createSheetDocument({rows:40,columns:12,name:"제목 없는 시트",columnWidth:120,rowHeight:32}),{structure:{minimumRows:1,minimumColumns:1}}), restore:value => createSheetEditor(value as SheetDocument,{structure:{minimumRows:1,minimumColumns:1}})})); const editor = stored.source; const snapshot = useEditingSnapshot(editor); diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index ba100662..edd8acbc 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -11,7 +11,7 @@ export function CanvasDemoRoute() { const [editor] = useState(() => createObjectEditor(emptyCanvasDocument)); const [selectProfile] = useState(() => createPlaneSelectProfile()); return ( - 한 장에 글자·도형·스티커 노트·그리기. 도형 안에도 더블클릭으로 글을 적습니다. Shift+클릭으로 다중 선택하고 Alt+드래그로 복제합니다. 스타일 아이콘에서 색·글자 서식·테두리를 바꾸고, 복사한 텍스트·이미지를 붙여넣습니다.}> + 한 장에 글자·도형·스티커 노트·표·그리기. 표는 더블클릭으로 셀 편집에 들어가고 Escape로 객체 선택에 돌아옵니다. 도형 안에도 더블클릭으로 글을 적습니다. Shift+클릭으로 다중 선택하고 Alt+드래그로 복제합니다. 스타일 아이콘에서 색·글자 서식·테두리를 바꾸고, 복사한 텍스트·이미지를 붙여넣습니다.}> ); diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx index 5a52ee16..c0844280 100644 --- a/site/src/routes/docs/DocsRoute.tsx +++ b/site/src/routes/docs/DocsRoute.tsx @@ -3,6 +3,7 @@ import { docPages, type DocPageId } from "./doc-pages"; import { DocumentationPage } from "./DocumentationPage"; const docIllustrations: Record = { + sheetDocumentApi: "braces", quickStart: "terminal", modules: "braces", handsSupport: "cursor", diff --git a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx index cc467e13..66b79889 100644 --- a/site/src/routes/docs/DocumentTypeCandidateRoute.tsx +++ b/site/src/routes/docs/DocumentTypeCandidateRoute.tsx @@ -39,7 +39,7 @@ fixture, copy, layout-only CSS, tests와 generated route는 제외합니다. ${ownerClosed ? `${name}의 모델·검증·의미 연산·projection은 \`${audit.closure.owner}\`가 소유합니다. Editing·Selection·React 없이 소비할 수 있으며 기존 Editing export는 호환 경로로 유지합니다. 소유권 확정은 wire Stable 승격을 뜻하지 않습니다.` - : `${name}은 현재 Document Type 후보입니다. 기존 package와 Hands에서 실제 책임을 옮기거나 완료를 선언하지 않았습니다.`} + : "statusNote" in profile ? profile.statusNote : `${name}은 현재 Document Type 후보입니다. 기존 package와 Hands에서 실제 책임을 옮기거나 완료를 선언하지 않았습니다.`} ## 목표 경계 · TBD diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts index 0301e555..3eee6541 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 = { + sheetDocumentApi: docPage("/docs/api/sheet-document"), sheetApi: docPage("/docs/api/sheet"), quickStart: docPage("/docs/quick-start"), modules: docPage("/docs/modules"), diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx index 460a3002..3e66f038 100644 --- a/site/src/routes/sheet-demo/SheetDemo.tsx +++ b/site/src/routes/sheet-demo/SheetDemo.tsx @@ -1,3 +1,4 @@ +import {sheetDocumentSchema} from "@interactive-os/json-document-sheet-document"; import { MarkdownCellEditor, MarkdownRenderer } from "@interactive-os/json-document-markdown-react"; import { useState } from "react"; import { createJSONDocument } from "@interactive-os/json-document"; @@ -25,7 +26,7 @@ const initialSheet: SheetDocument = { }; export function SheetDemo() { - const [sheet] = useState(() => createSheetEditor(initialSheet)); + const [sheet] = useState(() => createSheetEditor(sheetDocumentSchema.parse(initialSheet))); const [text] = useState(() => createTextEditor(createJSONDocument("| 문법 | 표현 |\n| --- | --- |\n| 강조 | **굵게** |"))); const [markdown] = useState(() => createMarkdownTableEditor(text, () => 0)); const [mode, setMode] = useState<"sheet" | "markdown">("sheet"); diff --git a/site/src/routes/sheet-demo/SheetViewsDemoRoute.tsx b/site/src/routes/sheet-demo/SheetViewsDemoRoute.tsx new file mode 100644 index 00000000..b406f365 --- /dev/null +++ b/site/src/routes/sheet-demo/SheetViewsDemoRoute.tsx @@ -0,0 +1,27 @@ +import {useState} from "react"; +import {createSheetEditor,type SheetDocument} from "@interactive-os/json-document-editing"; +import {SheetHand} from "@interactive-os/json-document-sheet"; +import {DemoPage} from "../../shared/demo-workbench/DemoPage"; +import {PageHeader} from "../../shared/ui/primitives"; + +const sample:SheetDocument={ + columns:[{id:"name",label:"이름"},{id:"status",label:"상태"},{id:"owner",label:"담당"}], + rows:[ + {id:"alpha",cells:{name:"Alpha",status:"초안",owner:"민아"}}, + {id:"beta",cells:{name:"Beta",status:"검토",owner:"태오"}}, + {id:"gamma",cells:{name:"Gamma",status:"완료",owner:"수진"}}, + ], +}; + +export function SheetViewsDemoRoute(){ + const [views]=useState(()=>{ + const source=createSheetEditor(sample,{structure:{minimumRows:1,minimumColumns:1}}); + return {left:source.createView(),right:source.createView({rowOrder:["gamma","beta","alpha"],columnOrder:["owner","status","name"]})}; + }); + return 어느 쪽에서 편집해도 같은 셀에 즉시 반영됩니다. 선택과 표시 순서는 각 View가 유지하고 실행 취소는 공유합니다.}> +
+

기본 순서

+

다른 순서

+
+
; +} diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 5c5fc737..e7a8a196 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,3 +1,14 @@ +import sheetViewSource from "../../../../packages/json-document-editing/src/sheet-view.ts?raw"; +import sheetPlanSource from "../../../../packages/json-document-editing/src/sheet-plan.ts?raw"; +import sheetDocumentCreateSource from "../../../../packages/json-document-sheet-document/src/create.ts?raw"; +import sheetDocumentSchemaSource from "../../../../packages/json-document-sheet-document/src/schema.ts?raw"; +import gridEditingProfileSource from "../../../../packages/json-document-affordance/src/grid-editing-profile.ts?raw"; +import elementCoordinateSource from "../../../../packages/json-document-web/src/element-coordinate.ts?raw"; +import sheetTextClipboardSource from "../../../../packages/json-document-editing/src/sheet-text-clipboard.ts?raw"; +import projectedSheetSource from "../../../../packages/json-document-editing/src/projected-sheet.ts?raw"; +import objectSheetSource from "../../../../packages/json-document-editing/src/object-sheet.ts?raw"; +import embeddedDocumentSource from "../../../../packages/json-document-object-document/src/embedded-document.ts?raw"; +import canvasSheetObjectSource from "../../../../packages/json-document-canvas/src/canvas-sheet-object.tsx?raw"; import storedDocumentSource from "../../../../packages/json-document-web/src/stored-document.ts?raw"; import sheetSummarySource from "../../../../packages/json-document-editing/src/sheet-summary.ts?raw"; import gridTraversalSource from "../../../../packages/json-document-selection/src/interaction/grid-traversal.ts?raw"; @@ -226,6 +237,19 @@ const excludedSources = new Set([ "routes/widgets/WidgetDemoFrame.tsx", ]); const registeredUsageSources = new Map([ + ["packages/json-document-affordance/src/grid-editing-profile.ts", gridEditingProfileSource], + ["packages/json-document-web/src/element-coordinate.ts", elementCoordinateSource], + ["packages/json-document-editing/src/sheet-text-clipboard.ts", sheetTextClipboardSource], + ["packages/json-document-editing/src/projected-sheet.ts", projectedSheetSource], + ["packages/json-document-sheet-document/src/schema.ts", sheetDocumentSchemaSource], + ["packages/json-document-sheet-document/src/create.ts", sheetDocumentCreateSource], + ["packages/json-document-editing/src/sheet-plan.ts", sheetPlanSource], + ["packages/json-document-editing/src/sheet-view.ts", sheetViewSource], + + ["packages/json-document-editing/src/object-sheet.ts", objectSheetSource], + ["packages/json-document-object-document/src/embedded-document.ts", embeddedDocumentSource], + ["packages/json-document-canvas/src/canvas-sheet-object.tsx", canvasSheetObjectSource], + ["packages/json-document-web/src/stored-document.ts", storedDocumentSource], ["packages/json-document-editing/src/sheet-summary.ts", sheetSummarySource], ["packages/json-document-selection/src/interaction/grid-traversal.ts", gridTraversalSource], @@ -427,10 +451,14 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredImplementationSources = new Map>([ + ["packages/json-document-editing/src/markdown-table.ts",["packages/json-document-editing/src/projected-sheet.ts"]], + ["packages/json-document-editing/src/object-sheet.ts",["packages/json-document-editing/src/projected-sheet.ts", "packages/json-document-object-document/src/embedded-document.ts"]], + ["packages/json-document-canvas/src/canvas-hand.tsx",["packages/json-document-canvas/src/canvas-sheet-object.tsx"]], + ["packages/json-document-sheet/src/sheet-hand.tsx", ["packages/json-document-sheet/src/sheet-axis-resize.tsx", "packages/json-document-sheet/src/sheet-range-selection.tsx", "packages/json-document-sheet/src/sheet-fill-handle.tsx"]], ["packages/json-document-editing/src/sheet-navigation.ts", ["packages/json-document-selection/src/interaction/grid-traversal.ts"]], ["packages/json-document-web/src/keyboard.ts", ["packages/json-document-selection/src/interaction/grid-traversal.ts"]], - ["packages/json-document-editing/src/sheet.ts", ["packages/json-document-editing/src/sheet-structure.ts", "packages/json-document-editing/src/sheet-navigation.ts"]], + ["packages/json-document-editing/src/sheet.ts", ["packages/json-document-editing/src/sheet-view.ts", "packages/json-document-editing/src/sheet-plan.ts", "packages/json-document-sheet-document/src/schema.ts", "packages/json-document-sheet-document/src/create.ts", "packages/json-document-editing/src/sheet-structure.ts", "packages/json-document-editing/src/sheet-navigation.ts"]], ["packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx", ["packages/json-document-editing/src/markdown-table.ts", "packages/json-document-markdown-react/src/MarkdownCellEditor.tsx"]], ["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"]], @@ -440,8 +468,93 @@ 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-web",symbol:"createWebStoredDocument",sourcePath:"packages/json-document-web/src/stored-document.ts"}, - {packageName:"@interactive-os/json-document-editing",symbol:"sheetSelectionSummary",sourcePath:"packages/json-document-editing/src/sheet-summary.ts"}, + {packageName: "@interactive-os/json-document-web", symbol: "webKeyboardPlatform", sourcePath: "packages/json-document-web/src/keyboard.ts"}, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "resolveGridEditActivation", + sourcePath: "packages/json-document-affordance/src/grid-editing-profile.ts", + }, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "gridEditingProfiles", + sourcePath: "packages/json-document-affordance/src/grid-editing-profile.ts", + }, + { + packageName: "@interactive-os/json-document-web", + symbol: "projectWebClientDeltaToElement", + sourcePath: "packages/json-document-web/src/element-coordinate.ts", + }, + { + packageName: "@interactive-os/json-document-web", + symbol: "sheetClipboardRepresentations", + sourcePath: "packages/json-document-web/src/clipboard.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "parseSheetClipboardText", + sourcePath: "packages/json-document-editing/src/sheet-text-clipboard.ts", + }, + { + packageName: "@interactive-os/json-document-sheet-document", + symbol: "createSheetDocument", + sourcePath: "packages/json-document-sheet-document/src/create.ts", + }, + { + packageName: "@interactive-os/json-document-sheet-document", + symbol: "sheetDocumentSchema", + sourcePath: "packages/json-document-sheet-document/src/schema.ts", + }, + { + packageName: "@interactive-os/json-document-sheet-document", + symbol: "assertSheetDocument", + sourcePath: "packages/json-document-sheet-document/src/schema.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "planSheetIntent", + sourcePath: "packages/json-document-editing/src/sheet-plan.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createProjectedSheetEditor", + sourcePath: "packages/json-document-editing/src/projected-sheet.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createCanvasSheet", + sourcePath: "packages/json-document-editing/src/object-sheet.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createObjectSheetEditor", + sourcePath: "packages/json-document-editing/src/object-sheet.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "sheetEmbeddedDocumentType", + sourcePath: "packages/json-document-editing/src/object-sheet.ts", + }, + { + packageName: "@interactive-os/json-document-object-document", + symbol: "createCanvasEmbeddedDocument", + sourcePath: "packages/json-document-object-document/src/embedded-document.ts", + }, + { + packageName: "@interactive-os/json-document-canvas", + symbol: "CanvasSheetObject", + sourcePath: "packages/json-document-canvas/src/canvas-sheet-object.tsx", + }, + + { + packageName: "@interactive-os/json-document-web", + symbol: "createWebStoredDocument", + sourcePath: "packages/json-document-web/src/stored-document.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "sheetSelectionSummary", + sourcePath: "packages/json-document-editing/src/sheet-summary.ts", + }, { packageName: "@interactive-os/json-document-selection", symbol: "traverseGrid", @@ -481,7 +594,7 @@ const registeredPublicUsages = [ { packageName: "@interactive-os/json-document-editing", symbol: "sheetColumnLabel", - sourcePath: "packages/json-document-editing/src/sheet-structure.ts", + sourcePath: "packages/json-document-sheet-document/src/create.ts", }, { packageName: "@interactive-os/json-document-react", diff --git a/site/tests/browser/bear.spec.ts b/site/tests/browser/bear.spec.ts index 9e7baf58..8bc6aea6 100644 --- a/site/tests/browser/bear.spec.ts +++ b/site/tests/browser/bear.spec.ts @@ -8,7 +8,8 @@ test("Bear shows only a centered document and supports editing with undo", async await expect(editor).toBeVisible(); await expect(page).toHaveTitle("Bear"); await expect(page.getByRole("navigation")).toHaveCount(0); - await expect(page.locator("main").getByRole("button")).toHaveCount(0); + // Embedded Sheet controls belong to the document; Bear has no surrounding toolbar. + await expect(page.locator("main button:not([data-sheet-hand] button)")).toHaveCount(0); const bounds = await editor.boundingBox(); expect(Math.abs(bounds!.x + bounds!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2); await editor.click(); diff --git a/site/tests/browser/canvas-sheet.spec.ts b/site/tests/browser/canvas-sheet.spec.ts new file mode 100644 index 00000000..51623aa8 --- /dev/null +++ b/site/tests/browser/canvas-sheet.spec.ts @@ -0,0 +1,49 @@ +import {expect,test} from "@playwright/test"; + +test("Canvas creates and edits a shared Sheet object with parent history and JSON persistence",async({page})=>{ + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto('/demo/canvas');await page.getByRole('button',{name:'표',exact:true}).click(); + const surface=page.locator('[data-canvas-slide]');const box=await surface.boundingBox(); + await page.mouse.click(box!.x+box!.width*.12,box!.y+box!.height*.18); + const object=page.locator('[data-canvas-object]');await expect(object).toHaveCount(1);await surface.focus();await surface.press("F2"); + const grid=page.getByRole('grid',{name:'Canvas 표'});await expect(grid).toBeVisible(); + const cells=grid.getByRole('gridcell');await cells.first().click();await page.keyboard.type('abc');await page.keyboard.press('Enter');await expect(cells.first()).toHaveText('abc'); + await cells.nth(3).press('Escape');await expect(surface).toBeFocused(); + const toolbar=page.getByRole('toolbar',{name:'Canvas tools'});await toolbar.getByRole('button',{name:'실행 취소',exact:true}).click(); + await object.dblclick();await expect(grid.getByRole('gridcell').first()).not.toHaveText('abc'); + await grid.getByRole('gridcell').first().press('Escape');await toolbar.getByRole('button',{name:'다시 실행',exact:true}).click(); + await object.dblclick();await expect(cells.first()).toHaveText('abc'); + await grid.getByRole('button',{name:'A 열 너비 조절'}).press('ArrowRight'); + await cells.first().press('Escape');await toolbar.getByRole('button',{name:'JSON',exact:true}).click(); + const json=page.getByRole('textbox',{name:'Canvas JSON document'}),saved=JSON.parse(await json.inputValue()); + expect(saved.objects[0].document.rows[0].cells[saved.objects[0].document.columns[0].id]).toBe('abc');expect(saved.objects[0].document.columns[0].width).toBeGreaterThan(120); + await page.getByRole('button',{name:'JSON 열기',exact:true}).click();await object.dblclick();await expect(cells.first()).toHaveText('abc'); + expect(errors).toEqual([]); +}); + +test("replacement typing, external paste, header focus and scaled resize share the same Sheet owner",async({page})=>{ + await page.goto('/applications/sheet');const grid=page.getByRole('grid'),cells=grid.getByRole('gridcell'); + await cells.first().click();await page.keyboard.type('abc');await page.keyboard.press('Enter');await expect(cells.first()).toHaveText('abc'); + await cells.first().click();await page.evaluate(()=>navigator.clipboard.writeText('a\tb\nc\td'));await page.keyboard.press('ControlOrMeta+v'); + for(const [index,value] of [[0,'a'],[1,'b'],[12,'c'],[13,'d']] as const)await expect(cells.nth(index)).toHaveText(value); + await grid.getByRole('button',{name:'A 열 선택',exact:true}).click();await page.keyboard.press('ArrowRight');await expect(cells.nth(469)).toBeFocused(); + await page.locator('[data-sheet-hand]').evaluate((element:HTMLElement)=>{element.style.transform='scale(0.5)';element.style.transformOrigin='top left';}); + await grid.getByRole('button',{name:'A 열 너비 조절'}).press('ArrowRight'); + expect(await page.evaluate(()=>JSON.parse(localStorage.getItem('json-document.sheet.v1')!).columns[0].width)).toBe(130); + await page.goto('/applications/bear');const bearCell=page.getByRole('gridcell').nth(2);await bearCell.click();await page.keyboard.type('abc');await page.keyboard.press('Enter');await expect(bearCell).toHaveText('abc'); +}); + +test("table preview survives horizontal, vertical and tiny creation drags",async({page})=>{ + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + for(const [dx,dy] of [[90,0],[0,90],[1,1]]) { + await page.goto('/demo/canvas');await page.getByRole('button',{name:'표',exact:true}).click(); + const surface=page.locator('[data-canvas-slide]'),box=await surface.boundingBox(); + const x=box!.x+box!.width*.15,y=box!.y+box!.height*.2; + await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+dx!,y+dy!,{steps:4}); + await expect(surface).toBeVisible();await page.mouse.up(); + const object=page.locator('[data-canvas-object]');await expect(object).toHaveCount(1); + expect(Number(await object.getAttribute('width'))).toBeGreaterThan(0);expect(Number(await object.getAttribute('height'))).toBeGreaterThan(0); + await page.getByRole('toolbar',{name:'Canvas tools'}).getByRole('button',{name:'실행 취소',exact:true}).click();await expect(object).toHaveCount(0); + } + expect(errors).toEqual([]); +}); diff --git a/site/tests/browser/canvas-slice.spec.ts b/site/tests/browser/canvas-slice.spec.ts index 010dbe8a..d59b9a87 100644 --- a/site/tests/browser/canvas-slice.spec.ts +++ b/site/tests/browser/canvas-slice.spec.ts @@ -5,7 +5,7 @@ for (const route of ["/demo/canvas", "/widgets/canvas"]) { await page.goto(route); const toolbar = page.getByRole("toolbar", { name: "Canvas tools" }); const buttons = toolbar.getByRole("button"); - await expect(buttons).toHaveCount(10); + await expect(buttons).toHaveCount(route === "/demo/canvas" ? 12 : 13); for (const button of await buttons.all()) { await expect(button).toHaveAttribute("data-ui-presentation", "icon"); await expect(button.locator('svg[aria-hidden="true"]')).toHaveCount(1); @@ -76,9 +76,9 @@ test("Canvas Alt-copy, nudge, duplicate and native selected clipboard share atom await page.keyboard.down("Alt"); await page.keyboard.down("Shift"); await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(end.x, end.y); await expect(page.locator("[data-canvas-copy-original]")).toHaveCount(2); - await expect(page.locator("[data-canvas-object]")).toHaveCount(3); + await expect(page.locator("[data-canvas-object]")).toHaveCount(4); await page.mouse.up(); await page.keyboard.up("Alt"); await page.keyboard.up("Shift"); - await expect(page.locator("[data-canvas-object]")).toHaveCount(5); + await expect(page.locator("[data-canvas-object]")).toHaveCount(6); await expect(page.locator('[data-canvas-object="rectangle"]')).toHaveAttribute("x", "96"); const selected = page.locator('[data-canvas-object][aria-pressed="true"]'); expect(Number(await selected.first().getAttribute("y"))).toBeCloseTo(264); @@ -86,17 +86,18 @@ test("Canvas Alt-copy, nudge, duplicate and native selected clipboard share atom expect(Number(await selected.first().getAttribute("x"))).toBeCloseTo(197); expect(Number(await selected.first().getAttribute("y"))).toBeCloseTo(274); await page.keyboard.press("ControlOrMeta+d"); - await expect(page.locator("[data-canvas-object]")).toHaveCount(7); + await expect(page.locator("[data-canvas-object]")).toHaveCount(8); await page.keyboard.press("ControlOrMeta+c"); await page.keyboard.press("ControlOrMeta+v"); - await expect(page.locator("[data-canvas-object]")).toHaveCount(9); + await expect(page.locator("[data-canvas-object]")).toHaveCount(10); await expect(selected).toHaveCount(2); - await page.keyboard.press("ControlOrMeta+x"); await expect(page.locator("[data-canvas-object]")).toHaveCount(7); - await page.keyboard.press("ControlOrMeta+z"); await expect(page.locator("[data-canvas-object]")).toHaveCount(9); + await page.keyboard.press("ControlOrMeta+x"); await expect(page.locator("[data-canvas-object]")).toHaveCount(8); + await page.keyboard.press("ControlOrMeta+z"); await expect(page.locator("[data-canvas-object]")).toHaveCount(10); await expect(selected).toHaveCount(2); }); test("Canvas captures real raster bytes before the native event ends and queues subsequent literal text", async ({ page }) => { await page.goto("/demo/canvas"); + await expect(page.locator("[data-canvas-slide]")).toBeVisible(); const prevented = await page.evaluate(() => { const raster = document.createElement("canvas"); raster.width = 1600; raster.height = 800; const context = raster.getContext("2d")!; context.fillStyle = "#b6c8e8"; context.fillRect(0, 0, 1600, 800); @@ -159,11 +160,11 @@ test("Plane Select moves a selected set with one Undo and collapses a plain clic await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(end.x, end.y, { steps: 6 }); expect(Number(await rectangle.getAttribute("x"))).toBeCloseTo(176); - expect(Number(await ellipse.getAttribute("x"))).toBeCloseTo(696); + expect(Number(await ellipse.getAttribute("x"))).toBeCloseTo(640); await page.mouse.up(); await page.getByRole("button", { name: "실행 취소" }).click(); expect(Number(await rectangle.getAttribute("x"))).toBeCloseTo(96); - expect(Number(await ellipse.getAttribute("x"))).toBeCloseTo(616); + expect(Number(await ellipse.getAttribute("x"))).toBeCloseTo(560); await expect(selected).toHaveCount(2); await expect(page.getByRole("button", { name: "실행 취소" })).toBeDisabled(); await rectangle.click(); await expect(selected).toHaveCount(1); @@ -172,7 +173,7 @@ test("Plane Select moves a selected set with one Undo and collapses a plain clic test("Plane Select marquee, cancellation and primary editing share the public profile", async ({ page }) => { await page.goto("/widgets/canvas"); const selected = page.locator('[data-canvas-object][aria-pressed="true"]'); - const start = await point(page, 50, 230), end = await point(page, 900, 530); + const start = await point(page, 50, 230), end = await point(page, 860, 530); await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(end.x, end.y); await expect(page.locator("[data-canvas-marquee]")).toHaveCount(1); await expect(selected).toHaveCount(2); await page.keyboard.press("Escape"); await page.mouse.up(); @@ -184,12 +185,12 @@ test("Plane Select marquee, cancellation and primary editing share the public pr await page.locator('[data-canvas-object="title"]').click({ modifiers: ["Shift"] }); await expect(selected).toHaveCount(3); await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("ControlOrMeta+a"); - await expect(selected).toHaveCount(3); await expect(page.locator("[data-resize-edge]")).toHaveCount(4); + await expect(selected).toHaveCount(4); await expect(page.locator("[data-resize-edge]")).toHaveCount(8); await page.keyboard.press("F2"); const text = page.getByRole("textbox", { name: "Canvas text", exact: true }); await text.fill("Reusable selection"); await text.press("ControlOrMeta+Enter"); await expect(page.locator('[data-canvas-object="title"]')).toHaveAttribute("aria-label", "Reusable selection"); - await expect(selected).toHaveCount(3); + await expect(selected).toHaveCount(4); await page.keyboard.press("ControlOrMeta+z"); await expect(page.locator('[data-canvas-object="title"]')).toHaveAttribute("aria-label", "One slide. Your ideas."); }); diff --git a/site/tests/browser/markdown-lists.spec.ts b/site/tests/browser/markdown-lists.spec.ts index 51bbb20e..33782e3e 100644 --- a/site/tests/browser/markdown-lists.spec.ts +++ b/site/tests/browser/markdown-lists.spec.ts @@ -28,7 +28,7 @@ for (const [prefix,next,nested] of [["- ","- "," - "],["1. ","2. "," 1. "],[" await page.keyboard.press("Enter"); await page.keyboard.press("Enter"); await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+next+"second\n\n"); await page.keyboard.insertText("outside"); - await expect(editor.locator('[data-markdown-kind="list"]')).not.toContainText("outside"); + await expect(editor.locator('[data-markdown-kind="list"]').filter({hasText:"outside"})).toHaveCount(0); }); } test("backward selection indents sibling items and their child together", async ({page}) => { @@ -51,3 +51,40 @@ test("ordinary text Tab releases focus and selected list syntax highlights its p await source(editor,"plain"); await page.keyboard.press("Tab"); await expect(editor).not.toBeFocused(); await expect.poll(()=>editor.textContent()).toBe("plain"); }); + +test("Bear promotes an empty nested item one level per Enter and keeps the insertion caret", async({page})=>{ + await page.goto("/applications/bear"); const editor=page.getByRole("textbox",{name:"Markdown 문서"}); + const original="- parent\n - child\n - "; + await source(editor,original); + await page.keyboard.press("Enter"); + await expect.poll(()=>editor.textContent()).toBe("- parent\n - child\n - "); + await page.keyboard.press("Enter"); + await expect.poll(()=>editor.textContent()).toBe("- parent\n - child\n- "); + await page.keyboard.press("ControlOrMeta+z"); + await expect.poll(()=>editor.textContent()).toBe("- parent\n - child\n - "); + await page.keyboard.press("ControlOrMeta+Shift+z"); + await expect.poll(()=>editor.textContent()).toBe("- parent\n - child\n- "); + await page.keyboard.insertText("sibling"); + await expect.poll(()=>editor.textContent()).toBe("- parent\n - child\n- sibling"); + await page.keyboard.press("Enter");await page.keyboard.press("Enter");await page.keyboard.insertText("outside"); + await expect(editor.locator('[data-markdown-kind="list"]').filter({hasText:"outside"})).toHaveCount(0); +}); +test("Bear outdents an item with its child without adopting following siblings",async({page})=>{ + await page.goto("/applications/bear");const editor=page.getByRole("textbox",{name:"Markdown 문서"}); + const original="- parent\n - moving\n - child\n - staying\n- next"; + await source(editor,original);await select(editor,original.indexOf("moving")+3); + await page.keyboard.press("Shift+Tab"); + const promoted="- parent\n - staying\n- moving\n - child\n- next"; + await expect.poll(()=>editor.textContent()).toBe(promoted); + await page.keyboard.press("ControlOrMeta+z");await expect.poll(()=>editor.textContent()).toBe(original); + await page.keyboard.press("ControlOrMeta+Shift+z");await expect.poll(()=>editor.textContent()).toBe(promoted); + await page.keyboard.insertText("X");await expect.poll(()=>editor.textContent()).toBe(promoted.replace("moving","movXing")); +}); +test("Bear splits rich list text with Enter and retains descendants",async({page})=>{ + await page.goto("/applications/bear");const editor=page.getByRole("textbox",{name:"Markdown 문서"}); + const original="- **bold** tail\n - child"; + await source(editor,original);await select(editor,original.indexOf("tail"));await page.keyboard.press("Enter"); + await expect.poll(()=>editor.textContent()).toBe("- **bold** \n- tail\n - child"); + await page.keyboard.insertText("new ");await expect.poll(()=>editor.textContent()).toBe("- **bold** \n- new tail\n - child"); + await expect(editor.locator("strong")).toHaveText("**bold**"); +}); diff --git a/site/tests/browser/sheet-demo.spec.ts b/site/tests/browser/sheet-demo.spec.ts index 4816ff6a..63a7e99a 100644 --- a/site/tests/browser/sheet-demo.spec.ts +++ b/site/tests/browser/sheet-demo.spec.ts @@ -136,3 +136,11 @@ test("Bear keeps inline formatting visible during cell editing and refuses unsup expect(await cell.boundingBox()).toEqual(before);await input.press('Escape'); await expect(cell.locator('strong')).toBeVisible();expect(await cell.boundingBox()).toEqual(before); }); + +test("Sheet Document API resolves its own route and links to executable table Usage",async({page})=>{ + await page.goto('/docs/api/sheet-document'); + await expect(page.getByRole('heading',{level:1,name:'Sheet Document API'})).toBeVisible(); + await expect(page.getByRole('heading',{level:2,name:'sheetDocumentSchema',exact:true})).toBeVisible(); + await page.getByRole('link',{name:'표 편집',exact:true}).click(); + await expect(page.getByRole('grid',{name:'Project sheet'})).toBeVisible(); +}); diff --git a/site/tests/browser/sheet-views.spec.ts b/site/tests/browser/sheet-views.spec.ts new file mode 100644 index 00000000..de770378 --- /dev/null +++ b/site/tests/browser/sheet-views.spec.ts @@ -0,0 +1,42 @@ +import {expect,test,type Locator} from "@playwright/test"; +const cell=(grid:Locator,row:string,column:string)=>grid.locator(`[data-grid-row-id="${row}"][data-grid-column-id="${column}"]`); + +test("two Views share edits and history while their ID selection and order remain independent",async({page})=>{ + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto('/demo/sheet-views'); + const left=page.getByRole('grid',{name:'기본 순서 표'}),right=page.getByRole('grid',{name:'다른 순서 표'}); + await expect(left.getByRole('gridcell').first()).toHaveText('Alpha');await expect(right.getByRole('gridcell').first()).toHaveText('수진'); + await cell(left,'beta','status').click(); + await cell(right,'gamma','owner').dblclick();await right.getByRole('textbox').fill('공유 편집');await right.getByRole('textbox').press('Enter'); + await expect(cell(left,'gamma','owner')).toHaveText('공유 편집');await expect(cell(left,'beta','status')).toHaveAttribute('aria-selected','true'); + const leftSection=page.getByRole('region',{name:'기본 순서 View'}),rightSection=page.getByRole('region',{name:'다른 순서 View'}); + await leftSection.getByRole('button',{name:'실행 취소',exact:true}).click();await expect(cell(right,'gamma','owner')).toHaveText('수진'); + await rightSection.getByRole('button',{name:'다시 실행',exact:true}).click();await expect(cell(left,'gamma','owner')).toHaveText('공유 편집'); + await cell(right,'gamma','owner').click();await cell(right,'gamma','owner').press('ArrowRight');await expect(cell(right,'gamma','status')).toBeFocused(); + await leftSection.getByRole('button',{name:'행 추가',exact:true}).click();await expect(left.getByRole('row')).toHaveCount(5);await expect(right.getByRole('row')).toHaveCount(5); + await leftSection.getByRole('button',{name:'열 추가',exact:true}).click();await expect(left.getByRole('gridcell')).toHaveCount(16);await expect(right.getByRole('gridcell')).toHaveCount(16); + await rightSection.getByRole('button',{name:'실행 취소',exact:true}).click();await expect(left.getByRole('gridcell')).toHaveCount(12); + await rightSection.getByRole('button',{name:'실행 취소',exact:true}).click();await expect(right.getByRole('gridcell')).toHaveCount(9); + expect(errors).toEqual([]); +}); + +test("copy, paste and fill follow the reordered View and update the other View",async({page})=>{ + await page.goto('/demo/sheet-views');const left=page.getByRole('grid',{name:'기본 순서 표'}),right=page.getByRole('grid',{name:'다른 순서 표'}); + await cell(right,'gamma','owner').click();await page.keyboard.down('Shift');await cell(right,'beta','status').click();await page.keyboard.up('Shift'); + await page.keyboard.press('ControlOrMeta+c');await expect.poll(()=>page.evaluate(()=>navigator.clipboard.readText())).toBe('수진\t완료\n태오\t검토'); + await cell(right,'gamma','owner').click();await page.evaluate(()=>navigator.clipboard.writeText('가\t나\n다\t라'));await page.keyboard.press('ControlOrMeta+v'); + await expect(cell(left,'gamma','owner')).toHaveText('가');await expect(cell(left,'gamma','status')).toHaveText('나');await expect(cell(left,'beta','owner')).toHaveText('다');await expect(cell(left,'beta','status')).toHaveText('라'); + await cell(right,'gamma','owner').click();await right.getByRole('button',{name:'선택 범위 채우기'}).click();await expect(cell(left,'beta','owner')).toHaveText('가'); +}); + +test("Mac Enter opens editing and commits into the other View",async({page})=>{ + await page.addInitScript(()=>Object.defineProperty(navigator,"platform",{get:()=>"MacIntel"})); + await page.goto('/demo/sheet-views'); + const left=page.getByRole('grid',{name:'기본 순서 표'}),right=page.getByRole('grid',{name:'다른 순서 표'}); + await cell(left,'alpha','name').click();await cell(left,'alpha','name').press('Enter'); + await expect(left.getByRole('textbox')).toHaveValue('Alpha'); + await left.getByRole('textbox').fill('Mac Enter');await left.getByRole('textbox').press('Enter'); + await expect(left.getByRole('textbox')).toHaveCount(0); + await expect(cell(right,'alpha','name')).toHaveText('Mac Enter'); + await expect(cell(left,'beta','name')).toBeFocused(); +}); diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index e1012c82..ec75a705 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -169,6 +169,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-web/src/keyboard.ts", "packages/json-document-selection/src/interaction/grid-traversal.ts", "packages/json-document-web/src/clipboard.ts", + "packages/json-document-editing/src/sheet-text-clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", "packages/json-document-editing/src/document.ts", ]); @@ -186,6 +187,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-web/src/keyboard.ts", "packages/json-document-selection/src/interaction/grid-traversal.ts", "packages/json-document-web/src/clipboard.ts", + "packages/json-document-editing/src/sheet-text-clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", "packages/json-document-editing/src/document.ts", ]); @@ -201,6 +203,7 @@ describe("Demo definition and source discovery", () => { "/docs/api/web", "/docs/api/selection", "/docs/api/web", + "/docs/api/editing", "/docs/api/react", "/docs/api/editing", ]); @@ -224,6 +227,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-selection/src/interaction/grid-traversal.ts", "packages/json-document-ui-primitives-react/src/toolbar.tsx", "packages/json-document-web/src/clipboard.ts", + "packages/json-document-editing/src/sheet-text-clipboard.ts", "packages/json-document-editing/src/database.ts", "packages/json-document/src/foundation/json/serializable.ts", "packages/json-document-editing/src/topology.ts", @@ -252,6 +256,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-react/src/use-editing.ts", "packages/json-document-react/src/editing-observation.ts", "packages/json-document-web/src/clipboard.ts", + "packages/json-document-editing/src/sheet-text-clipboard.ts", "packages/json-document-editing/src/object.ts", "packages/json-document-object-document/src/object-model.ts", "packages/json-document-object-document/src/object-style.ts", @@ -323,6 +328,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-selection/src/interaction/grid-traversal.ts", "packages/json-document-editing/src/tree.ts", "packages/json-document-web/src/clipboard.ts", + "packages/json-document-editing/src/sheet-text-clipboard.ts", "packages/json-document-react/src/use-tree-editing.ts", "packages/json-document-editing/src/tree-visibility.ts", ]); diff --git a/standards/repository-implementation-shape.md b/standards/repository-implementation-shape.md index 536eec2a..50d98516 100644 --- a/standards/repository-implementation-shape.md +++ b/standards/repository-implementation-shape.md @@ -333,3 +333,5 @@ Source를 이동하는 이슈는 다음을 모두 증명한다. 정본을 우회하지 않는다. Naming 변경이 함께 필요하면 `standards/repository-naming.md`를 먼저 통과한다. Public API, runtime 동작 또는 versioned protocol 변경은 각 contract의 별도 승인과 검증을 사용한다. + +- `packages/json-document-sheet-document`: Document Types. 공통 표 스키마·타입·검증·생성 정본이며 Editing·UI에 의존하지 않습니다. diff --git a/tsconfig.build.json b/tsconfig.build.json index e36fb000..be841dd8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -37,6 +37,7 @@ { "path": "./packages/json-document-rich-text-react" }, { "path": "./packages/json-document-collaboration" }, { "path": "./packages/contenteditable-collaboration" }, - { "path": "./packages/json-document-sheet" } + { "path": "./packages/json-document-sheet" }, + { "path": "./packages/json-document-sheet-document" } ] }