Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/json-document-editing/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -1648,6 +1649,11 @@ interface SheetSelection extends Record<string, JSONValue> {
readonly primaryIndex: number | null;
}
```
## `sheetSelectionSummary`

```ts
sheetSelectionSummary(editor: SheetEditor): { address: string; selected: number; filled: number; }
```
## `SheetStructureActions`

```ts
Expand All @@ -1673,6 +1679,7 @@ type SheetStructureIntent =
interface SheetStructurePolicy {
readonly headerRows?: number;
readonly minimumColumns?: number;
readonly minimumRows?: number;
}
```
## `SheetTopology`
Expand Down
4 changes: 4 additions & 0 deletions packages/json-document-editing/docs/sheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 로컬 저장을 조합합니다.
1 change: 1 addition & 0 deletions packages/json-document-editing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 2 additions & 0 deletions packages/json-document-editing/src/sheet-structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/json-document-editing/src/sheet-summary.ts
Original file line number Diff line number Diff line change
@@ -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};
}
7 changes: 7 additions & 0 deletions packages/json-document-editing/src/sheet-validation.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/json-document-editing/src/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -220,6 +221,7 @@ export function createSheetEditor(source: EditingDocumentSource<SheetDocument>,
}

function dispatch(intent: SheetIntent): EditingResult<SheetSelection> {
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");
Expand Down
13 changes: 13 additions & 0 deletions packages/json-document-editing/tests/sheet-application.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
4 changes: 3 additions & 1 deletion packages/json-document-sheet/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ interface SheetCellEditorProps {
## `SheetHand`

```ts
SheetHand({ editor, label, headerRow, profile, renderCell, renderEditor, onExit }: SheetHandProps): import("<repository>/node_modules/@types/react/jsx-runtime").JSX.Element
SheetHand({ editor, label, headerRow, coordinateHeaders, profile, renderCell, renderEditor, onExit }: SheetHandProps): import("<repository>/node_modules/@types/react/jsx-runtime").JSX.Element
```
## `SheetHandProps`

```ts
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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/json-document-sheet/docs/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`에서 확인합니다.
12 changes: 7 additions & 5 deletions packages/json-document-sheet/src/sheet-hand.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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. */
Expand All @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -107,9 +109,9 @@ export function SheetHand({editor, label = "표 편집", headerRow = false, prof
<div style={{overflowX: "auto"}} {...clipboard}>
<table role="grid" aria-label={label} aria-multiselectable="true" style={{borderCollapse: "collapse", width: "100%"}}>
<colgroup><col />{sheet.columns.map(column => <col key={column.id} style={{width:columnPreview?.id === column.id ? columnPreview.size : typeof column.width === "number" ? column.width : undefined}} />)}</colgroup>
<thead><tr><th aria-label="행 번호" />{sheet.columns.map(column => <th key={column.id} scope="col" style={{position:"relative"}}>
<button type="button" aria-label={`${column.label} 열 선택`} onClick={() => editor.dispatch({type:"selection.column",columnId:column.id})} style={{border:0,background:"transparent",font:"inherit",color:"inherit",padding:0}}>{column.label}</button>
{editor.capabilities.resize && <SheetAxisResize axis="x" label={`${column.label} 열 너비 조절`} onPreview={size => setColumnPreview(size === null ? null : {id:column.id,size})} onCommit={width => report(editor.dispatch({type:"column.resize",columnId:column.id,width}))} />}
<thead><tr><th aria-label="행 번호" />{sheet.columns.map((column,columnIndex) => <th key={column.id} scope="col" style={{position:"relative"}}>
<button type="button" aria-label={`${coordinateHeaders ? sheetColumnLabel(columnIndex) : column.label} 열 선택`} onClick={() => editor.dispatch({type:"selection.column",columnId:column.id})} style={{border:0,background:"transparent",font:"inherit",color:"inherit",padding:0}}>{coordinateHeaders ? sheetColumnLabel(columnIndex) : column.label}</button>
{editor.capabilities.resize && <SheetAxisResize axis="x" label={`${coordinateHeaders ? sheetColumnLabel(columnIndex) : column.label} 열 너비 조절`} onPreview={size => setColumnPreview(size === null ? null : {id:column.id,size})} onCommit={width => report(editor.dispatch({type:"column.resize",columnId:column.id,width}))} />}
</th>)}</tr></thead>
<tbody>{sheet.rows.map((row, index) => <tr key={row.id} style={{height:rowPreview?.id === row.id ? rowPreview.size : typeof row.height === "number" ? row.height : undefined}}><th scope="row" style={{position:"relative"}}>
<button type="button" aria-label={`${index + 1}행 선택`} onClick={() => editor.dispatch({type:"selection.row",rowId:row.id})} style={{border:0,background:"transparent",font:"inherit",color:"inherit",padding:0}}>{headerRow && index === 0 ? "제목" : index + (headerRow ? 0 : 1)}</button>
Expand Down
42 changes: 42 additions & 0 deletions packages/json-document-web/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ createWebKeyboardAdapter<Command>(options: { readonly keymap: WebKeymap<Command>
```ts
createWebPointerSession<State>(options?: WebPointerSessionOptions<State>): WebPointerSession<State>
```
## `createWebStoredDocument`

```ts
createWebStoredDocument<Source extends WebStoredDocumentSource>(options: WebStoredDocumentOptions<Source>): WebStoredDocument<Source>
```
## `createWebViewportPositionPorts`

```ts
Expand Down Expand Up @@ -551,6 +556,11 @@ type WebComposerFile = WebFileCandidate;
```ts
type WebComposerFileList = WebFileCandidateList;
```
## `WebDocumentSaveState`

```ts
type WebDocumentSaveState = "saved" | "unsaved" | "load-error" | "save-error";
```
## `WebDragDropCancelReason`

```ts
Expand Down Expand Up @@ -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<Source extends WebStoredDocumentSource> {
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<Source extends WebStoredDocumentSource> {
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
Expand Down
22 changes: 22 additions & 0 deletions packages/json-document-web/docs/stored-document.md
Original file line number Diff line number Diff line change
@@ -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은 이 모듈을 소비합니다.
1 change: 1 addition & 0 deletions packages/json-document-web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading