diff --git a/packages/json-document-editing/docs/api-reference.md b/packages/json-document-editing/docs/api-reference.md index 7c473772..fbdc4fe3 100644 --- a/packages/json-document-editing/docs/api-reference.md +++ b/packages/json-document-editing/docs/api-reference.md @@ -1575,6 +1575,7 @@ interface SheetEditorOptions extends EditingHistoryOptions { ```ts type SheetIntent = | SheetStructureIntent + | { readonly type: "sheet.rename"; readonly name: string } | { readonly type: "column.resize"; readonly columnId: string; readonly width: number } | { readonly type: "row.resize"; readonly rowId: string; readonly height: number } | { readonly type: "selection.range"; readonly range: SheetRange } @@ -1648,6 +1649,11 @@ interface SheetSelection extends Record { readonly primaryIndex: number | null; } ``` +## `sheetSelectionSummary` + +```ts +sheetSelectionSummary(editor: SheetEditor): { address: string; selected: number; filled: number; } +``` ## `SheetStructureActions` ```ts @@ -1673,6 +1679,7 @@ type SheetStructureIntent = interface SheetStructurePolicy { readonly headerRows?: number; readonly minimumColumns?: number; + readonly minimumRows?: number; } ``` ## `SheetTopology` diff --git a/packages/json-document-editing/docs/sheet.md b/packages/json-document-editing/docs/sheet.md index b7ff36f6..301b42c9 100644 --- a/packages/json-document-editing/docs/sheet.md +++ b/packages/json-document-editing/docs/sheet.md @@ -27,3 +27,7 @@ editor.dispatch(editor.structure.insertRow); `range.fill`은 source 사각형의 값 패턴을 target 사각형에 반복하고 한 번의 History transaction으로 확정합니다. `column.resize`와 `row.resize`는 각각 `width`와 `height`를 문서에 저장합니다. `editor.capabilities.resize`가 false면 UI와 직접 Intent 모두 이 작업을 허용하지 않습니다. Markdown adapter는 GFM에 크기 저장 문법이 없으므로 resize를 지원하지 않습니다. `sheetNavigationTarget`은 위 연속 입력의 좌표 투영 API이며 순환 순서는 Selection의 `traverseGrid`에 위임합니다. + +## 기본 Sheet 애플리케이션 + +`sheet.rename` Intent는 문서의 `name`을 History에 포함해 변경합니다. `structure.minimumRows`와 `minimumColumns`로 빈 축으로 인한 편집 불능을 방지합니다. `sheetSelectionSummary(editor)`는 활성 셀의 A1 좌표 및 선택/입력 셀 수를 제공합니다. `/applications/sheet`는 이 API와 SheetHand 및 Web 로컬 저장을 조합합니다. diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 10ee9baf..af16da14 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -236,3 +236,4 @@ export type { SheetStructureIntent, SheetStructurePolicy, SheetStructureActions 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"; diff --git a/packages/json-document-editing/src/sheet-structure.ts b/packages/json-document-editing/src/sheet-structure.ts index f36a50a3..eed10842 100644 --- a/packages/json-document-editing/src/sheet-structure.ts +++ b/packages/json-document-editing/src/sheet-structure.ts @@ -9,6 +9,7 @@ export type SheetStructureIntent = export interface SheetStructurePolicy { readonly headerRows?: number; readonly minimumColumns?: number; + readonly minimumRows?: number; } export interface SheetStructureActions { readonly insertRow: SheetStructureIntent; @@ -29,6 +30,7 @@ export function sheetStructureViolation(document: SheetDocument, intent: SheetSt const headerRows = policy.headerRows ?? 0; if (intent.type === "row.insert" && intent.index < headerRows) return "sheet.header-protected"; if (intent.type === "row.delete" && document.rows.findIndex(row => row.id === intent.rowId) >= 0 && document.rows.findIndex(row => row.id === intent.rowId) < headerRows) return "sheet.header-protected"; + if (intent.type === "row.delete" && document.rows.length <= (policy.minimumRows ?? 0)) return "sheet.minimum-rows"; if (intent.type === "column.delete" && document.columns.length <= (policy.minimumColumns ?? 0)) return "sheet.minimum-columns"; return null; } diff --git a/packages/json-document-editing/src/sheet-summary.ts b/packages/json-document-editing/src/sheet-summary.ts new file mode 100644 index 00000000..a893e1b6 --- /dev/null +++ b/packages/json-document-editing/src/sheet-summary.ts @@ -0,0 +1,14 @@ +import {jsonCellText} from "./cell-text.js"; +import {sheetColumnLabel} from "./sheet-structure.js"; +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 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); + const cells = editor.selectedCells; + return {address: row < 0 || column < 0 ? "" : `${sheetColumnLabel(column)}${row + 1}`, + selected: cells.length, filled: cells.filter(cell => jsonCellText(cell.value).length > 0).length}; +} diff --git a/packages/json-document-editing/src/sheet-validation.ts b/packages/json-document-editing/src/sheet-validation.ts index bf285bdc..b10b0bda 100644 --- a/packages/json-document-editing/src/sheet-validation.ts +++ b/packages/json-document-editing/src/sheet-validation.ts @@ -1,6 +1,13 @@ 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) { diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index f0fe526c..216e6dc6 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -87,6 +87,7 @@ export const sheetClipboardFormat = { export type SheetIntent = | SheetStructureIntent + | { readonly type: "sheet.rename"; readonly name: string } | { readonly type: "column.resize"; readonly columnId: string; readonly width: number } | { readonly type: "row.resize"; readonly rowId: string; readonly height: number } | { readonly type: "selection.range"; readonly range: SheetRange } @@ -220,6 +221,7 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + 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); if(!firstRow || !lastRow || !firstColumn || !lastColumn) return failure("selection.empty"); diff --git a/packages/json-document-editing/tests/sheet-application.test.ts b/packages/json-document-editing/tests/sheet-application.test.ts new file mode 100644 index 00000000..62939542 --- /dev/null +++ b/packages/json-document-editing/tests/sheet-application.test.ts @@ -0,0 +1,13 @@ +import {expect,test} from "vitest"; +import {createSheetEditor,sheetSelectionSummary} from "../src/index.js"; +test("document name participates in history and minimum axes stay editable",()=>{ + const editor=createSheetEditor({name:"빈 시트",columns:[{id:"a",label:"A"}],rows:[{id:"r",cells:{a:""}}]},{structure:{minimumRows:1,minimumColumns:1}}); + expect(editor.structure.deleteRow).toBeNull();expect(editor.structure.deleteColumn).toBeNull(); + expect(editor.dispatch({type:"row.delete",rowId:"r"}).ok).toBe(false); + editor.dispatch({type:"sheet.rename",name:"계획"});expect((editor.snapshot.value as {name:string}).name).toBe("계획");editor.undo();expect((editor.snapshot.value as {name:string}).name).toBe("빈 시트"); + editor.dispatch({type:"cell.commit",rowId:"r",columnId:"a",value:0});expect(sheetSelectionSummary(editor)).toEqual({address:"A1",selected:1,filled:1}); +}); + +test("rejects malformed restored sheet columns before rendering",()=>{ + expect(()=>createSheetEditor({columns:[{id:"a",label:{bad:true}}],rows:[]} as never)).toThrow("string ids and labels"); +}); diff --git a/packages/json-document-sheet/docs/api-reference.md b/packages/json-document-sheet/docs/api-reference.md index 217c08bd..75b2bb2e 100644 --- a/packages/json-document-sheet/docs/api-reference.md +++ b/packages/json-document-sheet/docs/api-reference.md @@ -21,7 +21,7 @@ interface SheetCellEditorProps { ## `SheetHand` ```ts -SheetHand({ editor, label, headerRow, profile, renderCell, renderEditor, onExit }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +SheetHand({ editor, label, headerRow, coordinateHeaders, profile, renderCell, renderEditor, onExit }: SheetHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element ``` ## `SheetHandProps` @@ -29,6 +29,8 @@ SheetHand({ editor, label, headerRow, profile, renderCell, renderEditor, onExit interface SheetHandProps { readonly editor: SheetEditor; readonly label?: string; + /** Display positional A/B/C headers for an application grid instead of field labels. */ + 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. */ diff --git a/packages/json-document-sheet/docs/editing.md b/packages/json-document-sheet/docs/editing.md index ee53d181..bf0cd975 100644 --- a/packages/json-document-sheet/docs/editing.md +++ b/packages/json-document-sheet/docs/editing.md @@ -33,3 +33,5 @@ const editor = createSheetEditor({columns: [{id: 'a', label: 'A'}], rows: [{id: 크기를 저장할 수 있는 editor에서는 행/열 경계 리사이즈를 제공합니다. 경계 핸들은 키보드로도 조작할 수 있으며 미리보기 후 확정할 때만 History에 기록합니다. GFM editor는 이 capability를 제공하지 않습니다. `renderEditor`는 포맷 소유 편집기를 받을 수 있습니다. Markdown 소비자는 `MarkdownCellEditor`를 연결하므로 편집 전후 서식도 유지합니다. 단순 문자열은 기존 Field를 사용합니다. `renderCell`은 읽기 표현이며, 맞는 포맷의 `renderEditor`와 함께 사용합니다. + +독립 Sheet 애플리케이션은 `coordinateHeaders`를 설정해 저장된 필드 label 대신 현재 순서의 A/B/C 헤더를 표시합니다. 열 삽입/삭제 후에도 좌표가 연속됩니다. 기본값은 필드 label을 유지하므로 문서 표와 기존 예제의 의미를 보존합니다. 실제 조합은 `/applications/sheet`에서 확인합니다. diff --git a/packages/json-document-sheet/src/sheet-hand.tsx b/packages/json-document-sheet/src/sheet-hand.tsx index 71f4ee9c..d6aa0f8f 100644 --- a/packages/json-document-sheet/src/sheet-hand.tsx +++ b/packages/json-document-sheet/src/sheet-hand.tsx @@ -1,6 +1,6 @@ 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 { jsonCellText, gridRangeBounds, gridCellsInRange, gridPointKey, type SheetRange, type SheetDocument, type SheetEditor, type GridPoint } from "@interactive-os/json-document-editing"; +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, editingCommandFromWebKeyboardStroke } from "@interactive-os/json-document-affordance"; import { isWebComposingKey, createWebClipboardSurface, findWebGridCell, gridBoundary, moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, webGridCellAddressProps } from "@interactive-os/json-document-web"; @@ -21,6 +21,8 @@ export interface SheetCellEditorProps { export interface SheetHandProps { readonly editor: SheetEditor; readonly label?: string; + /** Display positional A/B/C headers for an application grid instead of field labels. */ + 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. */ @@ -32,7 +34,7 @@ export interface SheetHandProps { } /** Shared cell selection, edit mode, clipboard and structural controls. Data/history stay with editor. */ -export function SheetHand({editor, label = "표 편집", headerRow = false, profile = "spreadsheet-grid", renderCell, renderEditor, onExit}: SheetHandProps) { +export function SheetHand({editor, label = "표 편집", headerRow = false, coordinateHeaders = false, profile = "spreadsheet-grid", renderCell, renderEditor, onExit}: SheetHandProps) { const snapshot = useEditingSnapshot(editor); const sheet = snapshot.value as SheetDocument; const surface = useRef(null); @@ -107,9 +109,9 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, prof
{sheet.columns.map(column => )} - )}{sheet.rows.map((row, index) =>
{sheet.columns.map(column => - - {editor.capabilities.resize && setColumnPreview(size === null ? null : {id:column.id,size})} onCommit={width => report(editor.dispatch({type:"column.resize",columnId:column.id,width}))} />} +
{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}))} />}
diff --git a/packages/json-document-web/docs/api-reference.md b/packages/json-document-web/docs/api-reference.md index f68251ab..948a1d04 100644 --- a/packages/json-document-web/docs/api-reference.md +++ b/packages/json-document-web/docs/api-reference.md @@ -104,6 +104,11 @@ createWebKeyboardAdapter(options: { readonly keymap: WebKeymap ```ts createWebPointerSession(options?: WebPointerSessionOptions): WebPointerSession ``` +## `createWebStoredDocument` + +```ts +createWebStoredDocument(options: WebStoredDocumentOptions): WebStoredDocument +``` ## `createWebViewportPositionPorts` ```ts @@ -551,6 +556,11 @@ type WebComposerFile = WebFileCandidate; ```ts type WebComposerFileList = WebFileCandidateList; ``` +## `WebDocumentSaveState` + +```ts +type WebDocumentSaveState = "saved" | "unsaved" | "load-error" | "save-error"; +``` ## `WebDragDropCancelReason` ```ts @@ -903,6 +913,38 @@ type WebRasterSourceResult = | { readonly ok: true; readonly dataURL: string; readonly width: number; readonly height: number } | { readonly ok: false; readonly code: "raster.read-failed" | "raster.decode-failed" | "raster.cancelled"; readonly reason?: string }; ``` +## `WebStoredDocument` + +```ts +interface WebStoredDocument { + readonly source: Source; + readonly state: WebDocumentSaveState; + subscribe(listener: () => void): () => void; + /** Observe value changes only. Returns cleanup; selection movement never writes storage. */ + connect(): () => void; + save(): void; +} +``` +## `WebStoredDocumentOptions` + +```ts +interface WebStoredDocumentOptions { + readonly key: string; + /** Lazy access also captures browsers that deny access to localStorage itself. */ + readonly storage: () => {getItem(key: string): string | null; setItem(key: string, value: string): void}; + /** Validate and construct the canonical document/editor, or throw on invalid stored data. */ + readonly restore: (value: unknown) => Source; + readonly create: () => Source; +} +``` +## `WebStoredDocumentSource` + +```ts +interface WebStoredDocumentSource { + readonly snapshot: {readonly value: JSONValue}; + subscribe(listener: () => void): () => void; +} +``` ## `WebSVGElement` ```ts diff --git a/packages/json-document-web/docs/stored-document.md b/packages/json-document-web/docs/stored-document.md new file mode 100644 index 00000000..3c3ce0a0 --- /dev/null +++ b/packages/json-document-web/docs/stored-document.md @@ -0,0 +1,22 @@ +# 로컬 문서 저장 + +`createWebStoredDocument`는 브라우저 storage와 정본 편집 source를 연결합니다. source의 `snapshot.value`가 바뀔 때 저장하고 선택만 바뀔 때는 저장하지 않습니다. `connect()`가 구독을 시작하며 반환한 cleanup으로 해제합니다. `subscribe`와 `state`로 `saved`, `unsaved`, `load-error`, `save-error`를 관찰하고 `save()`로 재시도합니다. + +```tsx +import {createWebStoredDocument} from '@interactive-os/json-document-web'; +import {createSheetEditor, type SheetDocument} from '@interactive-os/json-document-editing'; + +const stored = createWebStoredDocument({ + key: 'my-sheet', + storage: () => window.localStorage, + create: () => createSheetEditor(initialSheet), + restore: value => createSheetEditor(value as SheetDocument), // 생성자가 유효성을 검증 +}); +const disconnect = stored.connect(); +``` + +문서 유효성은 `restore`가 정본 생성자에 위임합니다. 읽기·JSON 파싱·복원이 실패하면 메모리에 새 문서를 만들고 실패 상태를 유지합니다. 이때 연결만으로 기존 저장값을 덮어쓰지 않습니다. 이후 실제 편집 또는 명시적인 저장 재시도는 새 내용을 저장합니다. 용량 초과 및 storage 접근 거부는 save-error로 전달합니다. + +이 저장소는 한 브라우저의 단일 문서용입니다. 여러 탭의 동시 편집 병합이나 클라우드 동기화는 제공하지 않습니다. History/Selection은 저장하지 않으며 현재 문서 값만 복원합니다. + +사이트 `/applications/sheet`가 실제 Usage입니다. Host는 storage key·초기 문서·오류 문구를 정하고, 저장 lifecycle은 이 모듈을 소비합니다. diff --git a/packages/json-document-web/src/index.ts b/packages/json-document-web/src/index.ts index 0ad34eea..18b08cd7 100644 --- a/packages/json-document-web/src/index.ts +++ b/packages/json-document-web/src/index.ts @@ -149,3 +149,4 @@ export type { } from "./grid-cell.js"; 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"; diff --git a/packages/json-document-web/src/stored-document.ts b/packages/json-document-web/src/stored-document.ts new file mode 100644 index 00000000..29033fa4 --- /dev/null +++ b/packages/json-document-web/src/stored-document.ts @@ -0,0 +1,53 @@ +import type {JSONValue} from "@interactive-os/json-document"; + +export interface WebStoredDocumentSource { + readonly snapshot: {readonly value: JSONValue}; + subscribe(listener: () => void): () => void; +} +export type WebDocumentSaveState = "saved" | "unsaved" | "load-error" | "save-error"; +export interface WebStoredDocumentOptions { + readonly key: string; + /** Lazy access also captures browsers that deny access to localStorage itself. */ + readonly storage: () => {getItem(key: string): string | null; setItem(key: string, value: string): void}; + /** Validate and construct the canonical document/editor, or throw on invalid stored data. */ + readonly restore: (value: unknown) => Source; + readonly create: () => Source; +} +export interface WebStoredDocument { + readonly source: Source; + readonly state: WebDocumentSaveState; + subscribe(listener: () => void): () => void; + /** Observe value changes only. Returns cleanup; selection movement never writes storage. */ + connect(): () => void; + save(): void; +} + +/** Browser persistence for one canonical source. History and selection remain with the source. */ +export function createWebStoredDocument(options: WebStoredDocumentOptions): WebStoredDocument { + let source: Source; + let state: WebDocumentSaveState = "unsaved"; + try { + const stored = options.storage().getItem(options.key); + if (stored === null) source = options.create(); + else {source = options.restore(JSON.parse(stored)); state = "saved";} + } catch {source = options.create(); state = "load-error";} + const listeners = new Set<() => void>(); + let observed = source.snapshot.value; + const publish = (next: WebDocumentSaveState) => {if (state !== next) {state = next; for (const listener of listeners) listener();}}; + const save = () => { + try {options.storage().setItem(options.key, JSON.stringify(source.snapshot.value)); publish("saved");} + catch {publish("save-error");} + }; + return { + source, get state() {return state;}, save, + subscribe(listener) {listeners.add(listener); return () => {listeners.delete(listener);};}, + connect() { + if (state === "unsaved") save(); + return source.subscribe(() => { + const value = source.snapshot.value; + if (value === observed) return; + observed = value; save(); + }); + }, + }; +} diff --git a/packages/json-document-web/tests/stored-document.test.ts b/packages/json-document-web/tests/stored-document.test.ts new file mode 100644 index 00000000..e730a167 --- /dev/null +++ b/packages/json-document-web/tests/stored-document.test.ts @@ -0,0 +1,24 @@ +import {expect,test} from "vitest"; +import {createWebStoredDocument} from "../src/stored-document.js"; +function source(initial: string) { + const listeners = new Set<() => void>(); + let value = {text:initial}; + return {get snapshot(){return {value};},subscribe(listener:()=>void){listeners.add(listener);return ()=>{listeners.delete(listener);};}, + edit(text:string){value={text};for(const listener of listeners) listener();},select(){for(const listener of listeners) listener();}}; +} +test("persists values, restores through the owner, skips selection, and disconnects",()=>{ + let stored:string|null=null,writes=0; + const storage=()=>({getItem:()=>stored,setItem:(_key:string,value:string)=>{stored=value;writes++;}}); + const options={key:"sheet",storage,create:()=>source(""),restore:(value:unknown)=>source((value as {text:string}).text)}; + const first=createWebStoredDocument(options);const disconnect=first.connect();expect(writes).toBe(1); + first.source.select();expect(writes).toBe(1);first.source.edit("한글");expect(writes).toBe(2); + expect(createWebStoredDocument(options).source.snapshot.value.text).toBe("한글"); + disconnect();first.source.edit("detached");expect(writes).toBe(2); +}); +test("preserves unreadable storage until an edit and exposes failed save/retry",()=>{ + let stored="invalid",denied=false; + const doc=createWebStoredDocument({key:"sheet",storage:()=>({getItem:()=>stored,setItem:(_key:string,value:string)=>{if(denied)throw Error("quota");stored=value;}}),create:()=>source(""),restore:()=>{throw Error("invalid");}}); + expect(doc.state).toBe("load-error");const off=doc.connect();expect(stored).toBe("invalid"); + denied=true;doc.source.edit("recover");expect(doc.state).toBe("save-error"); + denied=false;doc.save();expect(doc.state).toBe("saved");expect(JSON.parse(stored)).toEqual({text:"recover"});off(); +}); diff --git a/site/site-routes.json b/site/site-routes.json index e6d078a6..2750f814 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -92,6 +92,23 @@ ], "applicationSource": "site/src/app/routes/applications/bear.tsx" }, + { + "path": "/applications/sheet", + "label": "Sheet", + "title": "Sheet", + "description": "셀 편집, 범위 선택, 행열 조작과 로컬 저장을 제공하는 기본 Sheet 애플리케이션입니다.", + "language": "ko", + "navigationGroup": "Applications", + "chrome": "none", + "modulePaths": [ + "/docs/api/editing", + "/docs/api/react", + "/docs/api/web", + "/docs/api/sheet", + "/docs/api/ui-primitives-react" + ], + "applicationSource": "site/src/app/routes/applications/sheet.tsx" + }, { "path": "/applications/calendar", "label": "Calendar", @@ -275,7 +292,8 @@ "usagePaths": [ "/demo/history", "/demo/clipboard", - "/demo/markdown-caret" + "/demo/markdown-caret", + "/applications/sheet" ] }, "parentPath": "/docs/editing" @@ -400,14 +418,16 @@ "packages/json-document-web/docs/clipboard.md", "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/text-keys.md", + "packages/json-document-web/docs/stored-document.md" ], "module": { "packageName": "@interactive-os/json-document-web", "usagePaths": [ "/adapters/keyboard", "/adapters/clipboard", - "/demo/viewport" + "/demo/viewport", + "/applications/sheet" ] }, "parentPath": "/docs/adapters" @@ -2008,7 +2028,8 @@ "module": { "packageName": "@interactive-os/json-document-sheet", "usagePaths": [ - "/demo/sheet" + "/demo/sheet", + "/applications/sheet" ] }, "documentIncludes": [ diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index c5627830..22328c71 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as PageDemosRouteImport } from "./routes/_page/demos"; import { Route as PageEditorsRouteImport } from "./routes/_page/editors"; import { Route as PageViewerRouteImport } from "./routes/_page/viewer"; import { Route as ApplicationsBearRouteImport } from "./routes/applications/bear"; +import { Route as ApplicationsSheetRouteImport } from "./routes/applications/sheet"; import { Route as PageAdaptersIndexRouteImport } from "./routes/_page/adapters/index"; import { Route as PageAdaptersClipboardRouteImport } from "./routes/_page/adapters/clipboard"; import { Route as PageAdaptersContenteditableRouteImport } from "./routes/_page/adapters/contenteditable"; @@ -211,6 +212,11 @@ const ApplicationsBearRoute = ApplicationsBearRouteImport.update({ path: "/applications/bear", getParentRoute: () => rootRouteImport, } as any); +const ApplicationsSheetRoute = ApplicationsSheetRouteImport.update({ + id: "/applications/sheet", + path: "/applications/sheet", + getParentRoute: () => rootRouteImport, +} as any); const PageAdaptersIndexRoute = PageAdaptersIndexRouteImport.update({ id: "/adapters/", path: "/adapters/", @@ -1103,6 +1109,7 @@ export interface FileRoutesByFullPath { "/editors": typeof PageEditorsRoute; "/viewer": typeof PageViewerRoute; "/applications/bear": typeof ApplicationsBearRoute; + "/applications/sheet": typeof ApplicationsSheetRoute; "/adapters/clipboard": typeof PageAdaptersClipboardRoute; "/adapters/contenteditable": typeof PageAdaptersContenteditableRoute; "/adapters/keyboard": typeof PageAdaptersKeyboardRoute; @@ -1276,6 +1283,7 @@ export interface FileRoutesByTo { "/editors": typeof PageEditorsRoute; "/viewer": typeof PageViewerRoute; "/applications/bear": typeof ApplicationsBearRoute; + "/applications/sheet": typeof ApplicationsSheetRoute; "/adapters/clipboard": typeof PageAdaptersClipboardRoute; "/adapters/contenteditable": typeof PageAdaptersContenteditableRoute; "/adapters/keyboard": typeof PageAdaptersKeyboardRoute; @@ -1451,6 +1459,7 @@ export interface FileRoutesById { "/_page/editors": typeof PageEditorsRoute; "/_page/viewer": typeof PageViewerRoute; "/applications/bear": typeof ApplicationsBearRoute; + "/applications/sheet": typeof ApplicationsSheetRoute; "/_page/adapters/clipboard": typeof PageAdaptersClipboardRoute; "/_page/adapters/contenteditable": typeof PageAdaptersContenteditableRoute; "/_page/adapters/keyboard": typeof PageAdaptersKeyboardRoute; @@ -1626,6 +1635,7 @@ export interface FileRouteTypes { | "/editors" | "/viewer" | "/applications/bear" + | "/applications/sheet" | "/adapters/clipboard" | "/adapters/contenteditable" | "/adapters/keyboard" @@ -1799,6 +1809,7 @@ export interface FileRouteTypes { | "/editors" | "/viewer" | "/applications/bear" + | "/applications/sheet" | "/adapters/clipboard" | "/adapters/contenteditable" | "/adapters/keyboard" @@ -1973,6 +1984,7 @@ export interface FileRouteTypes { | "/_page/editors" | "/_page/viewer" | "/applications/bear" + | "/applications/sheet" | "/_page/adapters/clipboard" | "/_page/adapters/contenteditable" | "/_page/adapters/keyboard" @@ -2145,6 +2157,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute; PageRoute: typeof PageRouteWithChildren; ApplicationsBearRoute: typeof ApplicationsBearRoute; + ApplicationsSheetRoute: typeof ApplicationsSheetRoute; } declare module "@tanstack/react-router" { @@ -2191,6 +2204,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof ApplicationsBearRouteImport; parentRoute: typeof rootRouteImport; }; + "/applications/sheet": { + id: "/applications/sheet"; + path: "/applications/sheet"; + fullPath: "/applications/sheet"; + preLoaderRoute: typeof ApplicationsSheetRouteImport; + parentRoute: typeof rootRouteImport; + }; "/_page/adapters/": { id: "/_page/adapters/"; path: "/adapters"; @@ -3708,6 +3728,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, PageRoute: PageRouteWithChildren, ApplicationsBearRoute: ApplicationsBearRoute, + ApplicationsSheetRoute: ApplicationsSheetRoute, }; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/site/src/app/routes/applications/sheet.tsx b/site/src/app/routes/applications/sheet.tsx new file mode 100644 index 00000000..4fb6152e --- /dev/null +++ b/site/src/app/routes/applications/sheet.tsx @@ -0,0 +1,3 @@ +import {createFileRoute} from "@tanstack/react-router"; +import {SheetApplication} from "../../../applications/sheet/SheetApplication"; +export const Route = createFileRoute("/applications/sheet")({component:SheetApplication}); diff --git a/site/src/app/site-layers.ts b/site/src/app/site-layers.ts index d9d6b99e..df4057a6 100644 --- a/site/src/app/site-layers.ts +++ b/site/src/app/site-layers.ts @@ -12,7 +12,7 @@ export const siteSections: ReadonlyArray = [ { id: "getting-started", path: "/docs", label: "시작하기", blurb: "소개, 빠른 시작과 아키텍처", groups: ["Introduction"] }, { id: "modules", path: "/docs/modules", label: "모듈", blurb: "책임별 설명, API와 Usage", groups: ["JSON Document", "Document Types", "Editing", "Collaboration", "Adapter", "Connector", "Affordance", "UI Primitives"] }, { id: "hands", path: "/editors", label: "편집 조합 · Hands", blurb: "장르별 편집 예제와 지원 범위", groups: ["Hands"] }, - { id: "applications", path: "/applications", label: "Applications", blurb: "Bear, Calendar와 AI Agent", groups: ["Applications"] }, + { id: "applications", path: "/applications", label: "Applications", blurb: "Bear, Sheet, Calendar와 AI Agent", groups: ["Applications"] }, { id: "design", path: "/docs/design", label: "설계와 진행 상태", blurb: "설계 목표, 프로토타입과 소유권 감사", groups: ["Design", "Artifact"] }, ]; diff --git a/site/src/applications/sheet/SheetApplication.tsx b/site/src/applications/sheet/SheetApplication.tsx new file mode 100644 index 00000000..d6fc59ad --- /dev/null +++ b/site/src/applications/sheet/SheetApplication.tsx @@ -0,0 +1,35 @@ +import {useEffect, useState, useSyncExternalStore} from "react"; +import {createSheetEditor, sheetColumnLabel, 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"; +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}}), + restore:value => createSheetEditor(value as SheetDocument,{structure:{minimumRows:1,minimumColumns:1}})})); + const editor = stored.source; + const snapshot = useEditingSnapshot(editor); + const state = useSyncExternalStore(stored.subscribe,() => stored.state); + useEffect(() => stored.connect(),[stored]); + const summary = sheetSelectionSummary(editor); + const sheet = snapshot.value as SheetDocument; + const name = typeof sheet.name === "string" ? sheet.name : "제목 없는 시트"; + return
+
+ editor.dispatch({type:"sheet.rename",name})} presentation="seamless" /> + {state === "saved" ? "이 브라우저에 저장됨" : state === "load-error" ? "저장된 시트를 읽지 못했습니다. 새 내용은 편집하면 저장됩니다." : state === "save-error" ? "저장하지 못했습니다" : "저장 전"} + {(state === "save-error" || state === "load-error") && } +
+
+
{summary.address || "선택 없음"}{summary.selected}개 선택 · {summary.filled}개 입력됨F2 편집 · Enter 아래로 · Tab 옆으로
+
; +} diff --git a/site/src/applications/sheet/sheet-application.css b/site/src/applications/sheet/sheet-application.css new file mode 100644 index 00000000..30f84a95 --- /dev/null +++ b/site/src/applications/sheet/sheet-application.css @@ -0,0 +1,13 @@ +.sheet-application {height:100dvh;display:flex;flex-direction:column;font-size:13px;} +.sheet-application-heading {display:flex;align-items:center;gap:16px;padding:12px 20px;border-bottom:1px solid var(--color-border-subtle);} +.sheet-application-heading input {font-size:18px;font-weight:600;max-width:360px;min-width:0;} +.sheet-application-heading [role="status"] {margin-left:auto;} +.sheet-application-workspace {flex:1;min-height:0;overflow:auto;} +.sheet-application-workspace [data-sheet-hand] {min-height:100%;} +.sheet-application-workspace [role="toolbar"] {position:sticky;top:0;left:0;z-index:3;background:var(--color-background-canvas);padding:8px 16px;} +.sheet-application-workspace table {table-layout:fixed;min-width:1488px;} +.sheet-application-workspace table th {height:32px;font-weight:400;} +.sheet-application-workspace table th:first-child {width:48px;} +.sheet-application-footer {display:flex;align-items:center;gap:20px;padding:8px 20px;border-top:1px solid var(--color-border-subtle);} +.sheet-application-help {margin-left:auto;} +@media(max-width:640px) {.sheet-application-heading {padding:10px;gap:8px;}.sheet-application-heading input {width:180px;}.sheet-application-help {display:none;}} diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 6ccf50b7..5c5fc737 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,3 +1,5 @@ +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"; import sheetNavigationSource from "../../../../packages/json-document-editing/src/sheet-navigation.ts?raw"; import sheetAxisResizeSource from "../../../../packages/json-document-sheet/src/sheet-axis-resize.tsx?raw"; @@ -224,6 +226,8 @@ const excludedSources = new Set([ "routes/widgets/WidgetDemoFrame.tsx", ]); const registeredUsageSources = new Map([ + ["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], ["packages/json-document-editing/src/sheet-navigation.ts", sheetNavigationSource], ["packages/json-document-sheet/src/sheet-axis-resize.tsx", sheetAxisResizeSource], @@ -436,6 +440,8 @@ 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-selection", symbol: "traverseGrid", diff --git a/site/tests/browser/sheet-application.spec.ts b/site/tests/browser/sheet-application.spec.ts new file mode 100644 index 00000000..8af04192 --- /dev/null +++ b/site/tests/browser/sheet-application.spec.ts @@ -0,0 +1,27 @@ +import {expect,test} from "@playwright/test"; + +test("Sheet application edits, keeps geometry, persists title/data/size, and navigates a range",async({page})=>{ + const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto('/applications/sheet');const grid=page.getByRole('grid',{name:'Sheet'}),cells=grid.getByRole('gridcell'); + await expect(cells).toHaveCount(480); + const title=page.getByRole('textbox',{name:'시트 이름'});await title.fill('주간 계획'); + const cell=cells.nth(0);await cell.scrollIntoViewIfNeeded();const before=await cell.boundingBox(); + await cell.dblclick();const input=grid.getByRole('textbox');await input.fill('작업');expect(await cell.boundingBox()).toEqual(before);await input.press('Enter'); + await expect(cells.nth(12)).toBeFocused();await expect(cell).toHaveText('작업'); + await cell.click();await cells.nth(13).click({modifiers:['Shift']});await cells.nth(13).press('Tab');await expect(cell).toBeFocused();await expect(grid.locator('[data-selected="true"]')).toHaveCount(4); + const resize=grid.getByRole('button',{name:'A 열 너비 조절'});await resize.focus();await resize.press('ArrowRight'); + await expect(page.getByRole('main').getByRole('status')).toHaveText('이 브라우저에 저장됨');await page.reload(); + await expect(page.getByRole('textbox',{name:'시트 이름'})).toHaveValue('주간 계획');await expect(cells.nth(0)).toHaveText('작업'); + const saved=await page.evaluate(()=>JSON.parse(localStorage.getItem('json-document.sheet.v1')!));expect(saved.columns[0].width).toBeGreaterThan(120); + await cells.nth(0).click();await page.getByRole('button',{name:'행 추가',exact:true}).click();await expect(cells).toHaveCount(492); + await page.getByRole('button',{name:'실행 취소',exact:true}).click();await expect(cells).toHaveCount(480); + expect(errors).toEqual([]); +}); + +test("invalid saved document is visible and not overwritten on mount",async({page})=>{ + await page.addInitScript(()=>localStorage.setItem('json-document.sheet.v1','invalid')); + await page.goto('/applications/sheet');await expect(page.getByRole('main').getByRole('status')).toContainText('읽지 못했습니다'); + expect(await page.evaluate(()=>localStorage.getItem('json-document.sheet.v1'))).toBe('invalid'); + await page.getByRole('gridcell').first().dblclick();const input=page.getByRole('grid').getByRole('textbox');await input.fill('복구');await input.press('Enter'); + await expect(page.getByRole('main').getByRole('status')).toHaveText('이 브라우저에 저장됨'); +}); diff --git a/site/tests/unit/app-shell.test.tsx b/site/tests/unit/app-shell.test.tsx index 00658ba2..7901590b 100644 --- a/site/tests/unit/app-shell.test.tsx +++ b/site/tests/unit/app-shell.test.tsx @@ -64,7 +64,7 @@ describe("official site shell", () => { "설계 현황", "Official Hands 목표", "소유권 감사", "Artifact · Prototype", "개발 원칙", ])); await user.click(nav.getByRole("button", { name: "Applications" })); - expect(groupLinks(nav, "Applications")).toEqual(["Overview", "Bear", "Calendar", "AI Agent"]); + expect(groupLinks(nav, "Applications")).toEqual(["Overview", "Bear", "Sheet", "Calendar", "AI Agent"]); expect(nav.getByRole("link", { name: "JSON Document", exact: true }).getAttribute("href")).toBe("/docs/api"); expect(nav.getAllByRole("group").map((group) => group.getAttribute("aria-label"))).toEqual([ "시작하기", "모듈", "편집 조합 · Hands", "Applications", "설계와 진행 상태",