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 197e56d3..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 @@ -6824,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" @@ -6833,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": { @@ -7176,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", @@ -7315,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-editing/docs/api-reference.md b/packages/json-document-editing/docs/api-reference.md index 3c092a30..8fcf0651 100644 --- a/packages/json-document-editing/docs/api-reference.md +++ b/packages/json-document-editing/docs/api-reference.md @@ -1496,6 +1496,11 @@ parseSheetClipboardText(text: string): SheetClipboard | null ```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 @@ -1521,8 +1526,10 @@ projectCalendarOccurrences(events: ReadonlyArray, rangeStart: str ```ts interface ProjectedSheetOptions { readonly source: ProjectedSheetSource; - readonly read: () => SheetDocument | null; + /** 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; @@ -1543,6 +1550,11 @@ interface ProjectedSheetSource { ```ts projectTreeVisibility(nodes: ReadonlyArray, expandedIds: ReadonlySet): TreeVisibility ``` +## `SheetAvailability` + +```ts +type SheetAvailability = "ready" | "missing" | "invalid" | "readonly"; +``` ## `SheetCell` ```ts @@ -1567,10 +1579,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` @@ -1580,15 +1589,13 @@ 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 capabilities: {readonly resize: boolean}; readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; @@ -1661,6 +1668,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 @@ -1680,10 +1692,7 @@ interface SheetRange extends Record { ## `SheetRow` ```ts -interface SheetRow extends Record { - readonly id: string; - readonly cells: Readonly>; -} +type SheetRow = z.infer; ``` ## `SheetSelection` 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..c0e8d622 --- /dev/null +++ b/packages/json-document-editing/docs/sheet-contract.md @@ -0,0 +1,34 @@ +# 하나의 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). 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 6162b657..c13e1b3e 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -240,3 +240,7 @@ 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"; diff --git a/packages/json-document-editing/src/object-sheet.ts b/packages/json-document-editing/src/object-sheet.ts index 3f385636..fb19cbf0 100644 --- a/packages/json-document-editing/src/object-sheet.ts +++ b/packages/json-document-editing/src/object-sheet.ts @@ -1,17 +1,15 @@ import {createCanvasEmbeddedDocument,type CanvasObjectDraft,type ObjectBounds} from "@interactive-os/json-document-object-document"; import {createProjectedSheetEditor} from "./projected-sheet.js"; -import {assertSheetDocument} from "./sheet-validation.js"; -import {sheetColumnLabel} from "./sheet-structure.js"; +import {createSheetDocument} from "@interactive-os/json-document-sheet-document"; import type {ObjectEditor} from "./object.js"; -import type {SheetDocument,SheetEditor} from "./sheet.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 columns=Array.from({length:columnCount},(_,i)=>({id:`c${i}`,label:sheetColumnLabel(i),width:120})); - const document:SheetDocument={columns,rows:Array.from({length:rowCount},(_,i)=>({id:`r${i}`,cells:Object.fromEntries(columns.map(column=>[column.id,""]))}))}; + 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. */ @@ -20,7 +18,7 @@ export function createObjectSheetEditor(parent:ObjectEditor,objectId:string):She 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; - try{assertSheetDocument(object.document as SheetDocument);return object.document as SheetDocument;}catch{return null;} + return object.document; }, write(document){return parent.dispatch({type:"object.document",objectId,document});}, }); diff --git a/packages/json-document-editing/src/projected-sheet.ts b/packages/json-document-editing/src/projected-sheet.ts index a263de7a..28cc9b97 100644 --- a/packages/json-document-editing/src/projected-sheet.ts +++ b/packages/json-document-editing/src/projected-sheet.ts @@ -1,5 +1,8 @@ -import type {JSONValue} from "@interactive-os/json-document"; -import {createSheetEditor,type SheetDocument,type SheetEditor,type SheetEditorOptions,type SheetSelection} from "./sheet.js"; +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}; @@ -7,55 +10,69 @@ export interface ProjectedSheetSource { 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; - readonly read: () => SheetDocument | null; + /** 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; } -/** One projection lifecycle for embedded tables. Only the parent records persisted history. */ +/** Bind the common Sheet commands directly to the parent's transaction and history. */ export function createProjectedSheetEditor(options:ProjectedSheetOptions):SheetEditor { - let observed:JSONValue|undefined,engine:SheetEditor,available=false,revision=0; - let selection:SheetSelection|undefined; - const listeners=new Set[0]>(); + 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(engine && observed === value) return; - observed=value; - const document=options.read();available=document !== null; - engine=createSheetEditor(document ?? {rows:[],columns:[]},{...options.sheet,selection:selection ?? engine?.snapshot.selection}); - selection=undefined; + 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=()=>{read();return {...engine.snapshot,revision,canUndo:options.source.snapshot.canUndo,canRedo:options.source.snapshot.canRedo};}; + const snapshot=():EditingSnapshot=>{read();return {value:document,selection,revision,canUndo:options.source.snapshot.canUndo,canRedo:options.source.snapshot.canRedo};}; const publish=()=>{revision++;const next=snapshot();for(const listener of listeners)listener(next);}; - const commit=()=>{ - const value=engine.snapshot.value as SheetDocument; - selection=options.mapSelection?.(engine.snapshot.selection,value) ?? engine.snapshot.selection; - const result=options.write(value); - if(!result.ok) selection=undefined; - observed=undefined;publish(); - return result.ok ? {ok:true as const,snapshot:snapshot()} : {ok:false as const,code:result.code ?? "table.commit-failed"}; - }; const history=(action:"undo"|"redo")=>{ - const result=options.source[action]();publish(); - return result.ok ? {ok:true as const,snapshot:snapshot()} : {ok:false as const,code:result.code ?? "history.unavailable"}; + 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"}; }; - return { - get capabilities(){read();return engine.capabilities;},get structure(){read();return engine.structure;},get snapshot(){return snapshot();}, - get selectedCells(){read();return engine.selectedCells;},selectedCellsIn(topology){read();return engine.selectedCellsIn(topology);}, - dispatch(intent){ - read();if(!available)return {ok:false,code:"table.unavailable"}; - const before=engine.snapshot.value,result=engine.dispatch(intent);if(!result.ok)return result; - if(engine.snapshot.value === before){publish();return {ok:true,snapshot:snapshot()};} - return commit(); + 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"}; }, - copy(topology){read();return available ? engine.copy(topology):null;}, - cut(topology){read();if(!available)return null;const result=engine.cut(topology);return result && {clipboard:result.clipboard,result:result.result.ok ? commit():result.result};}, + 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(publish);return ()=>{listeners.delete(listener);if(!listeners.size){unsubscribe?.();unsubscribe=undefined;}};}, + 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 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-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.ts b/packages/json-document-editing/src/sheet.ts index 216e6dc6..1d179d05 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -1,3 +1,6 @@ +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 +31,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; @@ -120,7 +108,10 @@ export type SheetIntent = readonly topology?: SheetTopology; }; +export type SheetAvailability = "ready" | "missing" | "invalid" | "readonly"; + export interface SheetEditor { + readonly availability: SheetAvailability; readonly capabilities: {readonly resize: boolean}; readonly structure: SheetStructureActions; readonly snapshot: EditingSnapshot; @@ -146,11 +137,7 @@ 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 initialSelection = reconcileSheetSelection(initial, options.selection); const session = createEditingSession({ ...options, document, @@ -162,6 +149,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); @@ -287,6 +290,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); @@ -383,6 +387,7 @@ export function createSheetEditor(source: EditingDocumentSource, } return { + get availability() {return "ready" as const;}, get capabilities() {return {resize: options.resize !== false};}, get structure() { return sheetStructureActions(value(), session.snapshot.selection, options.structure ?? {}); }, get snapshot() { return session.snapshot; }, diff --git a/packages/json-document-editing/tests/object-sheet.test.ts b/packages/json-document-editing/tests/object-sheet.test.ts index a3e0947e..f65ab01b 100644 --- a/packages/json-document-editing/tests/object-sheet.test.ts +++ b/packages/json-document-editing/tests/object-sheet.test.ts @@ -8,13 +8,13 @@ test("embedded sheet writes one parent transaction and shares parent Undo, dupli 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:"r1",columnId:"c1",mode:"extend"});expect(changed).not.toHaveBeenCalled(); - expect(sheet.dispatch({type:"cell.commit",rowId:"r0",columnId:"c0",value:"abc",preserveSelection:true}).ok).toBe(true); - expect(changed).toHaveBeenCalledTimes(1);expect((sheet.snapshot.value as SheetDocument).rows[0]!.cells.c0).toBe("abc"); - sheet.undo();expect(parent.snapshot.value).toEqual(initial);parent.redo();expect((sheet.snapshot.value as SheetDocument).rows[0]!.cells.c0).toBe("abc"); + 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:"r0",columnId:"c0",value:"stale"}).ok).toBe(false); - parent.undo();expect((sheet.snapshot.value as SheetDocument).rows[0]!.cells.c0).toBe("abc");release(); + 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..57c730f6 --- /dev/null +++ b/packages/json-document-editing/tests/sheet-binding.test.ts @@ -0,0 +1,39 @@ +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(); +}); 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-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..1afa375c --- /dev/null +++ b/packages/json-document-sheet-document/src/schema.ts @@ -0,0 +1,28 @@ +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).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).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,size] of [["columns",document.columns,"width"],["rows",document.rows,"height"]] 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); + if(size in item && (typeof item[size] !== "number" || !Number.isFinite(item[size]) || item[size] <= 0))context.addIssue({code:"custom",path:[axis,index,size],message:"Sheet dimensions must be positive finite numbers."}); + }); + } + 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..9719f4eb --- /dev/null +++ b/packages/json-document-sheet-document/tests/schema.test.ts @@ -0,0 +1,22 @@ +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:[]}); +}); 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/src/sheet-hand.tsx b/packages/json-document-sheet/src/sheet-hand.tsx index e4152234..81d817a7 100644 --- a/packages/json-document-sheet/src/sheet-hand.tsx +++ b/packages/json-document-sheet/src/sheet-hand.tsx @@ -1,5 +1,5 @@ import { Rows3, Columns3, Plus, Minus, Undo2, Redo2 } from "lucide-react"; -import { useMemo, useRef, useState, type ReactNode, type KeyboardEvent, type CSSProperties, type KeyboardEventHandler, type FocusEventHandler } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode, type KeyboardEvent, type CSSProperties, type KeyboardEventHandler, type FocusEventHandler } from "react"; import { sheetColumnLabel, jsonCellText, gridRangeBounds, gridCellsInRange, gridPointKey, type SheetRange, type SheetDocument, type SheetEditor, type GridPoint } from "@interactive-os/json-document-editing"; import { editingItemProps, useEditingSnapshot, useGridEditing, useRenameSession } from "@interactive-os/json-document-react"; import { cellEditingAffordance, gridEditingProfiles, resolveGridEditActivation, editingCommandFromWebKeyboardStroke, type GridEditingProfile } from "@interactive-os/json-document-affordance"; @@ -40,6 +40,8 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, coor const policy = typeof profile === "string" ? gridEditingProfiles[profile] : profile; const snapshot = useEditingSnapshot(editor); const sheet = snapshot.value as SheetDocument; + const available = editor.availability; + const editable = available === "ready"; const surface = useRef(null); const [message, setMessage] = useState(""); const [columnPreview, setColumnPreview] = useState<{id:string;size:number} | null>(null); @@ -66,7 +68,12 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, coor tryCommit: (point, value) => report(editor.dispatch({type: "cell.commit", ...point, value, preserveSelection: true})), onFinish: focusCell, }); + useEffect(() => { + const current=rename.session.getSnapshot(); + if (!editable || current && (!sheet.rows.some(row=>row.id === current.key.rowId) || !sheet.columns.some(column=>column.id === current.key.columnId))) rename.session.cancel(); + }, [editable, sheet, rename.session]); const beginEdit = (point: GridPoint, replacement?: string) => { + if (!editable) return; const activation=resolveGridEditActivation(policy,jsonCellText(sheet.rows.find(row=>row.id === point.rowId)?.cells[point.columnId]),replacement); rename.session.begin(point,activation.draft,activation.initialSelection); }; @@ -107,23 +114,23 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, coor 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.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; @@ -141,12 +148,13 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, coor {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/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/site-routes.json b/site/site-routes.json index 82b7b2e7..af10310b 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", @@ -2041,5 +2042,22 @@ "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" + ] + }, + "parentPath": "/docs/document-types" } ] diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index 22328c71..0e9bb15d 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -170,6 +170,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"; @@ -1032,6 +1033,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", @@ -1261,6 +1268,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; @@ -1435,6 +1443,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; @@ -1611,6 +1620,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; @@ -1787,6 +1797,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" @@ -1961,6 +1972,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" @@ -2136,6 +2148,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" @@ -3289,6 +3302,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"; @@ -3531,6 +3551,7 @@ interface PageRouteChildren { PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; PageDocsApiSheetRoute: typeof PageDocsApiSheetRoute; + PageDocsApiSheetDocumentRoute: typeof PageDocsApiSheetDocumentRoute; PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; PageDocsApiWebRoute: typeof PageDocsApiWebRoute; @@ -3705,6 +3726,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/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/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/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index cf1e9f67..f6cdb880 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,3 +1,6 @@ +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"; @@ -237,6 +240,10 @@ const registeredUsageSources = new Map([ ["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/object-sheet.ts", objectSheetSource], ["packages/json-document-object-document/src/embedded-document.ts", embeddedDocumentSource], ["packages/json-document-canvas/src/canvas-sheet-object.tsx", canvasSheetObjectSource], @@ -449,7 +456,7 @@ const registeredImplementationSources = new Map>([ ["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-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"]], @@ -484,6 +491,26 @@ const registeredPublicUsages = [ 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", @@ -515,8 +542,16 @@ const registeredPublicUsages = [ 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-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", @@ -556,7 +591,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/canvas-sheet.spec.ts b/site/tests/browser/canvas-sheet.spec.ts index 050f29b4..51623aa8 100644 --- a/site/tests/browser/canvas-sheet.spec.ts +++ b/site/tests/browser/canvas-sheet.spec.ts @@ -16,7 +16,7 @@ test("Canvas creates and edits a shared Sheet object with parent history and JSO 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.c0).toBe('abc');expect(saved.objects[0].document.columns[0].width).toBeGreaterThan(120); + 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([]); }); 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/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" } ] }