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