diff --git a/docs/api-reference/contenteditable.md b/docs/api-reference/contenteditable.md index e94bc35b..8c6439f8 100644 --- a/docs/api-reference/contenteditable.md +++ b/docs/api-reference/contenteditable.md @@ -31,6 +31,10 @@ interface ContentEditableBindingOptions { readonly dom?: TextDOMAdapter; /** Optional canonical source editor; replaces direct commits with selection-restoring Editing transactions. */ readonly editor?: TextEditor; + /** Syntax-owned Enter command; paste and native composition text keep their original content. */ + readonly insertBreak?: (editor: TextEditor) => EditingResult; + /** Syntax-owned Tab action; null leaves native focus navigation available. */ + readonly indent?: (editor: TextEditor, direction: "indent" | "outdent") => EditingResult | null; } ``` ## `ContentEditableBindingResult` @@ -53,7 +57,17 @@ interface ContentEditableProps { ## `createContentEditableBinding` ```ts -createContentEditableBinding({ document, dom, pointer, root, editor, }: ContentEditableBindingOptions): ContentEditableBinding +createContentEditableBinding({ document, dom, pointer, root, editor, insertBreak, indent, }: ContentEditableBindingOptions): ContentEditableBinding +``` +## `createTextNavigationDOMAdapter` + +```ts +createTextNavigationDOMAdapter(base: TextDOMAdapter): TextDOMAdapter +``` +## `createTextProjectionDOMAdapter` + +```ts +createTextProjectionDOMAdapter(base: TextDOMAdapter, projections: (root: HTMLElement) => ReadonlyArray): TextDOMAdapter ``` ## `DOMObservation` @@ -73,13 +87,47 @@ const plainTextDOMAdapter: TextDOMAdapter ```ts renderTextCaretBoundary(root: HTMLElement, value: string): void ``` +## `restoreTextDOMSelection` + +```ts +restoreTextDOMSelection(root: HTMLElement, selection: TextSelection, options?: TextDOMSelectionOptions): boolean +``` ## `TextDOMAdapter` ```ts interface TextDOMAdapter { observe(root: HTMLElement): DOMObservation; render(root: HTMLElement, value: string, selection?: TextSelection | null): void; - restoreSelection(root: HTMLElement, selection: TextSelection): boolean; + restoreSelection(root: HTMLElement, selection: TextSelection, options?: TextDOMSelectionOptions): boolean; + /** Resolve one visual line while retaining the horizontal goal; null keeps native navigation. */ + resolveVerticalSelection?(root: HTMLElement, selection: TextSelection, direction: "backward" | "forward", extend: boolean): TextSelection | null; + /** Reset the horizontal goal after another input, pointer placement, or blur. */ + resetNavigation?(root: HTMLElement): void; + /** Resolve a source deletion range where native DOM deletion cannot preserve the projection. */ + resolveDeletionSelection?(root: HTMLElement, selection: TextSelection, direction: "backward" | "forward"): TextSelection | null; + /** Resolve a source-coordinate step across projected DOM boundaries; null keeps native navigation. */ + resolveHorizontalSelection?(root: HTMLElement, selection: TextSelection, direction: "backward" | "forward", extend: boolean): TextSelection | null; +} +``` +## `TextDOMSelectionOptions` + +```ts +interface TextDOMSelectionOptions { + /** Undefined preserves an equivalent live DOM endpoint; explicit affinity chooses a side. */ + readonly affinity?: (offset: number) => "backward" | "forward" | undefined; +} +``` +## `TextProjection` + +```ts +interface TextProjection { + readonly from: number; + readonly to: number; + readonly element: HTMLElement; + /** Optional next visible source position, after a concealed separator. */ + readonly following?: number; + /** Delete this displayed unit and its separator in one editing transaction. */ + readonly atomic?: boolean; } ``` ## `TextSelection` diff --git a/docs/api-reference/markdown-web.md b/docs/api-reference/markdown-web.md index 303740b5..4afb9796 100644 --- a/docs/api-reference/markdown-web.md +++ b/docs/api-reference/markdown-web.md @@ -9,5 +9,26 @@ Markdown source 위치와 caret에 따른 DOM projection의 public entrypoint입 ## `createMarkdownDOMAdapter` ```ts -createMarkdownDOMAdapter(): TextDOMAdapter +createMarkdownDOMAdapter(options?: MarkdownDOMOptions): TextDOMAdapter +``` +## `createMarkdownEditingBinding` + +```ts +createMarkdownEditingBinding({ editor, root }: MarkdownEditingBindingOptions): ContentEditableBinding +``` +## `MarkdownDOMOptions` + +```ts +interface MarkdownDOMOptions { + /** Enables task controls using the existing source editor and its history. */ + readonly editor?: TextEditor; +} +``` +## `MarkdownEditingBindingOptions` + +```ts +interface MarkdownEditingBindingOptions { + readonly editor: TextEditor; + readonly root: HTMLElement; +} ``` diff --git a/docs/api-reference/markdown.md b/docs/api-reference/markdown.md index ebde29e9..5ac4dd1c 100644 --- a/docs/api-reference/markdown.md +++ b/docs/api-reference/markdown.md @@ -11,6 +11,16 @@ ```ts createMarkdownParser(source: string): MarkdownParser ``` +## `indentMarkdownList` + +```ts +indentMarkdownList(source: string, selection: Selection, direction: "indent" | "outdent"): MarkdownSourceEdit | null +``` +## `insertMarkdownParagraph` + +```ts +insertMarkdownParagraph(source: string, selection: { readonly anchor: number; readonly focus: number; }): { readonly value: string; readonly selection: { readonly anchor: number; readonly focus: number; }; } +``` ## `MarkdownChangedRange` ```ts @@ -20,6 +30,22 @@ interface MarkdownChangedRange { readonly newTo: number; } ``` +## `MarkdownMarker` + +```ts +interface MarkdownMarker { + readonly kind: MarkdownMarkerKind; + readonly from: number; + readonly to: number; + /** Decoded character reference; other marker kinds have no replacement value. */ + readonly value?: string; +} +``` +## `MarkdownMarkerKind` + +```ts +type MarkdownMarkerKind = "heading" | "setext" | "list" | "blockquote" | "task" | "fence" | "code" | "emphasis" | "strong" | "delete" | "link" | "image" | "definition" | "table" | "thematicBreak" | "escape" | "break" | "footnote" | "entity"; +``` ## `MarkdownNode` ```ts @@ -62,6 +88,15 @@ interface MarkdownProjection { readonly source: string; readonly nodes: ReadonlyArray; readonly strong: ReadonlyArray; + readonly markers: ReadonlyArray; +} +``` +## `MarkdownSourceEdit` + +```ts +interface MarkdownSourceEdit { + readonly value: string; + readonly selection: { readonly anchor: number; readonly focus: number }; } ``` ## `MarkdownStrongSpan` @@ -87,3 +122,8 @@ interface MarkdownUpdate { ```ts projectMarkdown(source: string): MarkdownProjection ``` +## `setMarkdownTaskChecked` + +```ts +setMarkdownTaskChecked(source: string, from: number, checked: boolean): string +``` diff --git a/package-lock.json b/package-lock.json index 447130fb..20f66b41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1445,9 +1445,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3342,6 +3339,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6868,9 +6866,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { - "@interactive-os/json-document-contenteditable": "*", "@interactive-os/json-document-editing": "*", - "@interactive-os/json-document-markdown": "*", "@interactive-os/json-document-markdown-web": "*", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", @@ -6883,9 +6879,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { - "@interactive-os/json-document-contenteditable": "^0.1.0-rc.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" } @@ -6898,11 +6892,11 @@ "@interactive-os/json-document-contenteditable": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-markdown": "*", + "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "jsdom": "^29.1.1", "typescript": "^5.0.0", - "vitest": "^4.1.7", - "@interactive-os/json-document-web": "*" + "vitest": "^4.1.7" }, "peerDependencies": { "@interactive-os/json-document-contenteditable": "^0.1.0-rc.0", diff --git a/packages/json-document-contenteditable/docs/editing.md b/packages/json-document-contenteditable/docs/editing.md index 299a56b6..05ee5f8a 100644 --- a/packages/json-document-contenteditable/docs/editing.md +++ b/packages/json-document-contenteditable/docs/editing.md @@ -154,3 +154,7 @@ const unbind = binding.bind(); - 현재 계약은 수평 writing mode의 원문 문자열 편집입니다. 수직 writing mode 또는 DOM 측정이 없는 환경은 native에 맡깁니다. Alt/Ctrl/Meta 방향키, PageUp/Down, Home/End의 플랫폼 명령은 가로채지 않습니다. 구조화된 Rich Text의 node-point 선택 모델은 이 API의 입력 계약이 아닙니다. [Markdown caret Usage](/demo/markdown-caret)에서 제목·인용·목록·코드·표를 ↑↓와 Shift+↑↓로 이동할 수 있으며, Source에서 화면 줄 탐색과 caret 가시성 모듈까지 확인할 수 있습니다. 문서 원문·편집·History의 owner는 기존 Editing이고, 입력 이벤트 수명은 contenteditable binding에 남습니다. + +`ContentEditableBindingOptions.indent(editor, direction)`는 문법 소유 Tab 명령을 주입합니다. `direction`은 `"indent" | "outdent"`입니다. 반환값 `null`은 native focus 이동을 유지하고 EditingResult가 있으면 키를 소비합니다. 키 해석은 Web keyboard adapter, IME·입력 lease 배타성은 binding이 소유합니다. 수정키 조합이나 IME 조합 중에는 호출하지 않습니다. + +투영 구간에 선택이 겹치면 `data-text-projection-selected`를 표시해 보이는 기호 영역에 선택 배경을 그립니다. 투명한 원문 글자의 native 선택 배경은 숨겨 이중 표시를 막으며, 복사·선택 offset은 그대로 유지합니다. diff --git a/packages/json-document-contenteditable/src/dom/text-projection.css b/packages/json-document-contenteditable/src/dom/text-projection.css index 2a56c783..1ed53f3b 100644 --- a/packages/json-document-contenteditable/src/dom/text-projection.css +++ b/packages/json-document-contenteditable/src/dom/text-projection.css @@ -13,3 +13,8 @@ [data-text-projection-edge="before"]::after { left: 0; } [data-text-projection-edge="after"]::after { left: 100%; } [data-text-projection-caret]:not(:focus-within) [data-text-projection-edge]::after { display: none; } + +/* Selection belongs to the visible projection, not its transparent source glyphs. */ +[data-text-projection-source]::selection, +[data-text-projection-source] *::selection { background: transparent; color: transparent; } +[data-text-projection-selected] { background-color: Highlight; } diff --git a/packages/json-document-contenteditable/src/dom/text-projection.ts b/packages/json-document-contenteditable/src/dom/text-projection.ts index cf412258..959caed1 100644 --- a/packages/json-document-contenteditable/src/dom/text-projection.ts +++ b/packages/json-document-contenteditable/src/dom/text-projection.ts @@ -19,6 +19,9 @@ export function createTextProjectionDOMAdapter( const paint = (root: HTMLElement, selection: TextSelection | null): void => { let active = false; for (const region of projections(root)) { + const selected = selection !== null && selection.anchor !== selection.focus + && Math.max(selection.anchor,selection.focus) > region.from && Math.min(selection.anchor,selection.focus) < region.to; + if (region.element.hasAttribute("data-text-projection-selected") !== selected) region.element.toggleAttribute("data-text-projection-selected", selected); const focus = selection?.anchor === selection?.focus ? selection?.focus : undefined; const edge = !active && focus !== undefined && focus >= region.from && focus <= region.to ? (focus - region.from < region.to - focus ? "before" : "after") : null; diff --git a/packages/json-document-contenteditable/src/lease.ts b/packages/json-document-contenteditable/src/lease.ts index dd88ce65..77d0dd1a 100644 --- a/packages/json-document-contenteditable/src/lease.ts +++ b/packages/json-document-contenteditable/src/lease.ts @@ -34,6 +34,7 @@ export function createContentEditableBinding({ root, editor, insertBreak = (editor) => editor.insert("\n"), + indent, }: ContentEditableBindingOptions): ContentEditableBinding { if (editor && (editor.document !== document || editor.pointer !== pointer)) { throw new TypeError("contenteditable editor must own the bound document and pointer"); @@ -47,6 +48,7 @@ export function createContentEditableBinding({ let rendering = false; let compositionEnter: { timeStamp: number; keyCode: number; released: boolean } | null = null; const keyboard = createWebKeyboardAdapter(); + const indentationKeyboard = createWebKeyboardAdapter<"indent" | "outdent">({defaults:false,keymap:{Tab:"indent", "Shift-Tab":"outdent"}}); let diagnosticEvent: Event | undefined; let unregisterDiagnosticSource: (() => void) | null = null; let unsubscribeDiagnosticDocument: (() => void) | null = null; @@ -266,6 +268,19 @@ export function createContentEditableBinding({ if (editor && event.type === "keydown" && activeLease?.phase !== "composing") { const keyboardEvent = event as KeyboardEvent; const command = keyboard.resolve(keyboardEvent); + const indentation = indentationKeyboard.resolve(keyboardEvent); + if (indent && indentation && !activeLease && !trailingComposition && !keyboardEvent.isComposing && keyboardEvent.keyCode !== 229) { + const selection = currentDOMSelection(); + if (selection) { + dom.resetNavigation?.(root); + editor.select(selection); + const result = indent(editor, indentation); + if (result) { + event.preventDefault(); + return result.ok ? COMMITTED : failure(result.code, result.reason ?? result.code); + } + } + } const vertical = command?.type === "move" && (command.direction === "up" || command.direction === "down") && !keyboardEvent.altKey && !keyboardEvent.ctrlKey && !keyboardEvent.metaKey; if (!vertical && !["Shift", "Control", "Alt", "Meta"].includes(keyboardEvent.key)) dom.resetNavigation?.(root); diff --git a/packages/json-document-contenteditable/src/types.ts b/packages/json-document-contenteditable/src/types.ts index a621fbae..301312c0 100644 --- a/packages/json-document-contenteditable/src/types.ts +++ b/packages/json-document-contenteditable/src/types.ts @@ -35,6 +35,8 @@ export interface ContentEditableBindingOptions { readonly editor?: TextEditor; /** Syntax-owned Enter command; paste and native composition text keep their original content. */ readonly insertBreak?: (editor: TextEditor) => EditingResult; + /** Syntax-owned Tab action; null leaves native focus navigation available. */ + readonly indent?: (editor: TextEditor, direction: "indent" | "outdent") => EditingResult | null; } export type ContentEditableBindingResult = diff --git a/packages/json-document-contenteditable/tests/text-projection.test.ts b/packages/json-document-contenteditable/tests/text-projection.test.ts index daa09083..da648746 100644 --- a/packages/json-document-contenteditable/tests/text-projection.test.ts +++ b/packages/json-document-contenteditable/tests/text-projection.test.ts @@ -144,3 +144,14 @@ test("plain source rendering retains the live text node when only selection chan plainTextDOMAdapter.render(root, "changed"); expect(root.textContent).toBe("changed"); }); + + +test("selection highlights the visible projection and clears it when collapsed", () => { + const {root,marker,adapter} = fixture(); + adapter.render(root,"[[key]] 😀본문",{anchor:0,focus:7}); + expect(marker.hasAttribute("data-text-projection-selected")).toBe(true); + expect(marker.hasAttribute("data-text-projection-edge")).toBe(false); + adapter.render(root,"[[key]] 😀본문",{anchor:7,focus:7}); + expect(marker.hasAttribute("data-text-projection-selected")).toBe(false); + expect(marker.getAttribute("data-text-projection-edge")).toBe("after"); +}); diff --git a/packages/json-document-markdown-react/docs/editing.md b/packages/json-document-markdown-react/docs/editing.md index c2dd54cf..9ce65953 100644 --- a/packages/json-document-markdown-react/docs/editing.md +++ b/packages/json-document-markdown-react/docs/editing.md @@ -1,7 +1,7 @@ ## Markdown 원문 편집 surface -`MarkdownEditingSurface`는 `TextEditor`를 받아 Markdown Web projection과 기존 -contenteditable 입력 수명을 React에 연결합니다. React는 편집 DOM의 children을 +`MarkdownEditingSurface`는 `TextEditor`를 받아 Markdown Web의 공개 +`createMarkdownEditingBinding` 생성·해제를 React 수명에 연결합니다. React는 편집 DOM의 children을 관리하지 않습니다. 문서 모델·명령·selection·history는 Editing이 소유합니다. ```tsx @@ -31,4 +31,4 @@ streaming용 문법 보정 결과는 편집 원문에 기록하지 않습니다. todo 전체를 한 번에 지우며, Undo 한 번으로 복원합니다. 기호 표시와 원자 삭제 정책은 Markdown Web과 공용 contenteditable 투영을 사용합니다. -인용문 Enter는 Markdown의 `insertMarkdownParagraph`를 공용 contenteditable `insertBreak`에 연결합니다. 내용이 있으면 인용을 이어 쓰고, 빈 인용 줄에서는 일반 문단으로 나갑니다. 결과는 기존 editor에 한 번 적용하므로 Undo/Redo와 원문 선택을 유지합니다. +인용문 Enter 연결은 Markdown Web binding이 소유하며 Markdown의 `insertMarkdownParagraph`를 공용 contenteditable `insertBreak`에 연결합니다. 내용이 있으면 인용을 이어 쓰고, 빈 인용 줄에서는 일반 문단으로 나갑니다. 결과는 기존 editor에 한 번 적용하므로 Undo/Redo와 원문 선택을 유지합니다. diff --git a/packages/json-document-markdown-react/package.json b/packages/json-document-markdown-react/package.json index 94a12890..4359a918 100644 --- a/packages/json-document-markdown-react/package.json +++ b/packages/json-document-markdown-react/package.json @@ -44,9 +44,7 @@ "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "@interactive-os/json-document-editing": "^0.1.0-rc.0", - "@interactive-os/json-document-contenteditable": "^0.1.0-rc.0", - "@interactive-os/json-document-markdown-web": "^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" }, "devDependencies": { "@testing-library/react": "^16.3.2", @@ -59,8 +57,6 @@ "typescript": "^5.0.0", "vitest": "^4.1.7", "@interactive-os/json-document-editing": "*", - "@interactive-os/json-document-contenteditable": "*", - "@interactive-os/json-document-markdown-web": "*", - "@interactive-os/json-document-markdown": "*" + "@interactive-os/json-document-markdown-web": "*" } } diff --git a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx index ec35064d..0566cd03 100644 --- a/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx +++ b/packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx @@ -1,8 +1,6 @@ -import { insertMarkdownParagraph } from "@interactive-os/json-document-markdown"; import { useEffect, useRef, type HTMLAttributes } from "react"; import type { TextEditor } from "@interactive-os/json-document-editing"; -import { createContentEditableBinding } from "@interactive-os/json-document-contenteditable"; -import { createMarkdownDOMAdapter } from "@interactive-os/json-document-markdown-web"; +import { createMarkdownEditingBinding } from "@interactive-os/json-document-markdown-web"; export interface MarkdownEditingSurfaceProps extends Omit, "children" | "contentEditable"> { readonly editor: TextEditor; @@ -14,14 +12,7 @@ export function MarkdownEditingSurface({ editor, style, ...props }: MarkdownEdit useEffect(() => { const root = rootRef.current; if (!root) return; - const binding = createContentEditableBinding({ - document: editor.document, pointer: editor.pointer, editor, root, - insertBreak: editor => { - const next = insertMarkdownParagraph(editor.text, editor.snapshot.selection); - return editor.replace(next.value, next.selection); - }, - dom: createMarkdownDOMAdapter({editor}), - }); + const binding = createMarkdownEditingBinding({editor, root}); return binding.bind(); }, [editor]); return
; diff --git a/packages/json-document-markdown-react/tsconfig.json b/packages/json-document-markdown-react/tsconfig.json index 937eec44..b47894dd 100644 --- a/packages/json-document-markdown-react/tsconfig.json +++ b/packages/json-document-markdown-react/tsconfig.json @@ -10,9 +10,11 @@ "src/**/*.tsx" ], "references": [ - { "path": "../json-document-editing" }, - { "path": "../json-document-contenteditable" }, - { "path": "../json-document-markdown-web" }, - { "path": "../json-document-markdown" } + { + "path": "../json-document-editing" + }, + { + "path": "../json-document-markdown-web" + } ] } diff --git a/packages/json-document-markdown-web/docs/api.md b/packages/json-document-markdown-web/docs/api.md index 5c6bd206..676a1d38 100644 --- a/packages/json-document-markdown-web/docs/api.md +++ b/packages/json-document-markdown-web/docs/api.md @@ -57,16 +57,12 @@ HTML은 실행하지 않는 원문으로 표시합니다. 체크 상태는 원 ```ts import { createJSONDocument } from "@interactive-os/json-document"; import { createTextEditor } from "@interactive-os/json-document-editing"; -import { createContentEditableBinding } from "@interactive-os/json-document-contenteditable"; -import { createMarkdownDOMAdapter } from "@interactive-os/json-document-markdown-web"; +import { createMarkdownEditingBinding } from "@interactive-os/json-document-markdown-web"; import "@interactive-os/json-document-markdown-web/markdown-editor.css"; const document = createJSONDocument("A **source**"); const editor = createTextEditor(document); -const binding = createContentEditableBinding({ - document, pointer: "", editor, root, - dom: createMarkdownDOMAdapter({ editor }), -}); +const binding = createMarkdownEditingBinding({ editor, root }); const dispose = binding.bind(); // dispose() releases DOM listeners and native leases. ``` @@ -112,3 +108,16 @@ Markdown 문자열이 정본이고 CommonMark + GFM은 문법 의미를 결정 ## 보이는 줄을 따르는 수직 이동 `createMarkdownDOMAdapter`는 contenteditable의 공개 `createTextNavigationDOMAdapter`와 `createTextProjectionDOMAdapter`를 조합합니다. ↑↓는 숨긴 원문 기호 대신 보이는 본문 줄을 따라 가로 위치를 유지하고, Shift는 원래 anchor를 유지합니다. 별도의 Markdown 문법별 방향키 분기는 없습니다. [공용 화면 줄 이동 계약](/docs/api/contenteditable)과 [Usage](/demo/markdown-caret)에서 문서 끝·빈 줄·스크롤·native fallback의 범위를 확인할 수 있습니다. + +## 프레임워크 독립 Markdown 편집 binding + +`createMarkdownEditingBinding({ editor, root }): ContentEditableBinding`는 Markdown DOM, +인용 Enter 이어 쓰기·빈 줄 탈출, task와 기존 Editing History를 한 계약으로 연결합니다. +`MarkdownEditingBindingOptions`는 `TextEditor`와 `HTMLElement`를 요구합니다. +`bind()`가 반환하는 해제 함수를 호출하면 contenteditable의 입력 lease와 이벤트 연결을 해제합니다. +호스트는 root의 `contentEditable`, 접근성 속성, 레이아웃과 공개 CSS import를 담당합니다. +React `MarkdownEditingSurface`와 직접 Web 소비는 이 API를 함께 사용합니다. +저수준 `createMarkdownDOMAdapter`는 DOM 투영만 필요한 소비자를 위해 유지합니다. +[Usage](/demo/markdown-caret)의 소스에서 binding과 문법 명령까지 추적할 수 있습니다. + +`createMarkdownEditingBinding`은 Markdown의 목록 Enter 및 `indentMarkdownList`를 contenteditable의 `insertBreak`·`indent` 주입 경계에 연결합니다. Tab/Shift+Tab은 목록에서만 소비하고 IME 조합 중에는 실행하지 않습니다. source·선택 변경은 기존 editor.replace 한 번이므로 Undo/Redo 역시 한 번입니다. React와 직접 Web 소비가 같은 명령을 사용합니다. diff --git a/packages/json-document-markdown-web/src/editing-binding.ts b/packages/json-document-markdown-web/src/editing-binding.ts new file mode 100644 index 00000000..199e36c3 --- /dev/null +++ b/packages/json-document-markdown-web/src/editing-binding.ts @@ -0,0 +1,25 @@ +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"; + +export interface MarkdownEditingBindingOptions { + readonly editor: TextEditor; + readonly root: HTMLElement; +} + +/** Connect Markdown DOM, syntax-owned Enter, and the editor's existing history. */ +export function createMarkdownEditingBinding({editor, root}: MarkdownEditingBindingOptions): ContentEditableBinding { + return createContentEditableBinding({ + document: editor.document, pointer: editor.pointer, editor, root, + dom: createMarkdownDOMAdapter({editor}), + indent(editor, direction) { + const next = indentMarkdownList(editor.text, editor.snapshot.selection, direction); + return next ? editor.replace(next.value, next.selection) : null; + }, + insertBreak(editor) { + const next = insertMarkdownParagraph(editor.text, editor.snapshot.selection); + return editor.replace(next.value, next.selection); + }, + }); +} diff --git a/packages/json-document-markdown-web/src/index.ts b/packages/json-document-markdown-web/src/index.ts index 9a4f6d7a..68cdc51b 100644 --- a/packages/json-document-markdown-web/src/index.ts +++ b/packages/json-document-markdown-web/src/index.ts @@ -1 +1,2 @@ export { createMarkdownDOMAdapter, type MarkdownDOMOptions } from "./markdown-dom.js"; +export { createMarkdownEditingBinding, type MarkdownEditingBindingOptions } from "./editing-binding.js"; diff --git a/packages/json-document-markdown-web/tests/editing-binding.test.ts b/packages/json-document-markdown-web/tests/editing-binding.test.ts new file mode 100644 index 00000000..13dfebeb --- /dev/null +++ b/packages/json-document-markdown-web/tests/editing-binding.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createTextEditor } from "@interactive-os/json-document-editing"; +import { plainTextDOMAdapter } from "@interactive-os/json-document-contenteditable"; +import { createMarkdownEditingBinding } from "../src/index.js"; + +const disposals: Array<() => void> = []; +afterEach(() => { disposals.splice(0).forEach(dispose => dispose()); vi.useRealTimers(); document.getSelection()?.removeAllRanges(); document.body.replaceChildren(); }); +function setup(source: string) { + const root = document.createElement("div"); root.contentEditable = "true"; document.body.append(root); + const editor = createTextEditor(createJSONDocument(source)); + const binding = createMarkdownEditingBinding({editor, root}); + const dispose = binding.bind(); disposals.push(dispose); + const select = (focus: number) => { editor.select({anchor:focus,focus}); plainTextDOMAdapter.restoreSelection(root,{anchor:focus,focus}); }; + select(source.length); + return {root, editor, dispose, select}; +} +test("direct binding continues and exits quotes using existing Undo and releases input listeners", () => { + const {root, editor, dispose} = setup("> first"); + const enter = () => root.dispatchEvent(new InputEvent("beforeinput", {inputType:"insertParagraph",cancelable:true})); + enter(); expect(editor.text).toBe("> first\n> "); + enter(); expect(editor.text).toBe("> first\n\n"); + editor.undo(); expect(editor.text).toBe("> first\n> "); + editor.redo(); expect(editor.text).toBe("> first\n\n"); + dispose(); enter(); expect(editor.text).toBe("> first\n\n"); +}); +test("IME-confirmed quote source uses the same Markdown Enter command", () => { + vi.useFakeTimers(); + const {root, editor} = setup("> "); + root.dispatchEvent(new CompositionEvent("compositionstart")); + root.replaceChildren(document.createTextNode("> 한")); + plainTextDOMAdapter.restoreSelection(root,{anchor:3,focus:3}); + root.dispatchEvent(new KeyboardEvent("keydown", {key:"Enter",code:"Enter",isComposing:true,cancelable:true})); + root.dispatchEvent(new CompositionEvent("compositionend", {data:"한"})); + vi.runAllTimers(); + expect(editor.text).toBe("> 한\n> "); + editor.undo(); expect(editor.text).toBe("> 한"); + editor.undo(); expect(editor.text).toBe("> "); +}); + + +test("Tab changes parsed list nesting with undo and leaves ordinary focus navigation native", () => { + const {root,editor,select} = setup("- one\n- two"); + const key = (shiftKey=false) => { const event=new KeyboardEvent("keydown",{key:"Tab",shiftKey,cancelable:true}); root.dispatchEvent(event); return event; }; + expect(key().defaultPrevented).toBe(true); + expect(editor.text).toBe("- one\n - two"); + expect(key(true).defaultPrevented).toBe(true); + expect(editor.text).toBe("- one\n- two"); + editor.undo(); expect(editor.text).toBe("- one\n - two"); + editor.undo(); expect(editor.text).toBe("- one\n- two"); + editor.replace("plain",{anchor:5,focus:5}); select(5); + expect(key().defaultPrevented).toBe(false); +}); + +test("IME owns Tab until list text is committed, then Enter uses the list command", () => { + vi.useFakeTimers(); + const {root,editor} = setup("- one\n- "); + root.dispatchEvent(new CompositionEvent("compositionstart")); + const tab=new KeyboardEvent("keydown",{key:"Tab",isComposing:true,cancelable:true}); root.dispatchEvent(tab); + expect(tab.defaultPrevented).toBe(false); + expect(editor.text).toBe("- one\n- "); + root.replaceChildren(document.createTextNode("- one\n- 한")); + plainTextDOMAdapter.restoreSelection(root,{anchor:9,focus:9}); + root.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",code:"Enter",isComposing:true,cancelable:true})); + root.dispatchEvent(new CompositionEvent("compositionend",{data:"한"})); + vi.runAllTimers(); + expect(editor.text).toBe("- one\n- 한\n- "); +}); diff --git a/packages/json-document-markdown/docs/api.md b/packages/json-document-markdown/docs/api.md index 05f2cd20..9ecfc5bb 100644 --- a/packages/json-document-markdown/docs/api.md +++ b/packages/json-document-markdown/docs/api.md @@ -117,3 +117,11 @@ DOM·History는 소유하지 않으며 소비자가 반환값을 기존 TextEdit `insertMarkdownParagraph(source, {anchor, focus})`는 선택을 줄바꿈으로 교체할 원문 `value`와 다음 `selection`을 반환합니다. 인용 줄은 `> ` 접두사를 다음 줄에 이어 붙이고, 본문이 공백뿐인 인용 줄에서는 접두사를 제거하고 인용 밖의 빈 문단으로 이동합니다. 빈 줄 경계를 남겨 이후 입력이 CommonMark의 lazy continuation으로 다시 인용에 들어가지 않게 합니다. 중첩 인용은 접두사를 유지하고 빈 줄에서는 인용 전체를 나갑니다. 일반 문단과 코드 안의 `>`는 일반 줄바꿈입니다. 저장·Undo는 기존 TextEditor가 소유합니다. [Markdown caret Usage](/demo/markdown-caret)의 React surface가 이 공개 API를 import하여 contenteditable의 `insertBreak` 명령으로 연결합니다. 붙여넣기에는 적용하지 않습니다. + +## 목록 편집 + +`insertMarkdownParagraph(source, selection)`는 파서가 인식한 목록에서도 동작합니다. 본문은 같은 항목 문법으로 이어 쓰고 번호 목록은 다음 번호, task는 미체크 상태로 이어 씁니다. 빈 항목은 목록 접두사를 제거하고 일반 문단 경계를 남깁니다. 감싼 인용 접두사는 유지합니다. 코드·HTML 내부는 목록처럼 보이는 문자만으로 이어 쓰지 않습니다. 빈 task 초안 `[ ]`도 목록 종료 대상으로 처리합니다. + +`indentMarkdownList(source, selection, direction: "indent" | "outdent"): MarkdownSourceEdit | null`은 선택한 형제 항목과 하위 내용을 함께 이동합니다. Tab은 앞 형제의 본문 들여쓰기 폭을 사용하고 Shift+Tab은 부모 목록으로 승격합니다. 중첩 번호는 1부터, 승격 번호는 부모 다음부터 시작합니다. 첫 항목의 들여쓰기와 최상위 내어쓰기는 변경 없이 반환하며 목록 밖에서는 `null`입니다. 원문 CRLF와 선택 방향을 보존하며 변경된 들여쓰기는 공백으로 표현합니다. + +`MarkdownSourceEdit`는 `{ value, selection: { anchor, focus } }`입니다. 원문·선택은 UTF-16 offset이며 원문 범위 밖 선택은 RangeError입니다. 명령은 DOM·키 이벤트·History를 소유하지 않습니다. [Usage](/demo/markdown-caret)에서 Web binding을 통해 실행합니다. diff --git a/packages/json-document-markdown/src/index.ts b/packages/json-document-markdown/src/index.ts index 4cdbd44e..0a00c8dc 100644 --- a/packages/json-document-markdown/src/index.ts +++ b/packages/json-document-markdown/src/index.ts @@ -4,3 +4,5 @@ export type { MarkdownNode, MarkdownNodeKind } from "./nodes.js"; export type { MarkdownMarker, MarkdownMarkerKind } from "./markers.js"; export { setMarkdownTaskChecked } from "./tasks.js"; export { insertMarkdownParagraph } from "./paragraph.js"; +export { indentMarkdownList } from "./list-editing.js"; +export type { MarkdownSourceEdit } from "./source-edit.js"; diff --git a/packages/json-document-markdown/src/list-editing.ts b/packages/json-document-markdown/src/list-editing.ts new file mode 100644 index 00000000..2e3468fe --- /dev/null +++ b/packages/json-document-markdown/src/list-editing.ts @@ -0,0 +1,136 @@ +import { assertMarkdownSelection, type MarkdownSourceEdit } from "./source-edit.js"; +import { projectMarkdown, type MarkdownProjection } from "./projection.js"; +import type { MarkdownNode } from "./nodes.js"; + +type Selection = MarkdownSourceEdit["selection"]; +interface Item { + node: MarkdownNode; list: MarkdownNode; parent: Item | null; index: number; + lineFrom: number; quoteEnd: number; markerFrom: number; markerTo: number; + bodyFrom: number; contentIndent: number; indentColumns: number; taskFrom?: number; +} +const lineStart = (source: string, offset: number) => offset === 0 ? 0 : source.lastIndexOf("\n", offset - 1) + 1; +const lineEnd = (source: string, offset: number) => { const end = source.indexOf("\n", offset); return end < 0 ? source.length : end; }; +function columns(value: string): number { let width=0; for(const character of value) width += character === "\t" ? 4-width%4 : 1; return width; } +function items(source: string, projection: MarkdownProjection): Item[] { + const result: Item[] = []; + const visit = (nodes: readonly MarkdownNode[], parent: Item | null, list: MarkdownNode | null) => { + nodes.forEach((node, index) => { + let owner = parent; + if (node.kind === "listItem" && list) { + const start = lineStart(source, node.from), end = lineEnd(source,node.from); + const marker = projection.markers.find(m => m.kind === "list" && m.from >= node.from && m.to <= end); + if (marker) { + const spaces = /^[ \t]*/.exec(source.slice(marker.to,end))![0]; + const body = marker.to + spaces.length; + const task = projection.markers.find(m => m.kind === "task" && m.from === body && m.to <= end) + ?? (/^\[[ xX]\][ \t\r]*$/.test(source.slice(body,end)) ? {from:body,to:body+3} : undefined); + const quote = /^(?:[ \t]*>[ \t]?)+/.exec(source.slice(start,marker.from))?.[0] ?? ""; + owner = {node,list,parent,index,lineFrom:start,quoteEnd:start+quote.length,markerFrom:marker.from,markerTo:marker.to, + bodyFrom:task ? task.to + (/^[ \t]*/.exec(source.slice(task.to,end))![0].length) : body, + indentColumns:columns(source.slice(start+quote.length,marker.from)), + contentIndent:columns(source.slice(start+quote.length,body))-columns(source.slice(start+quote.length,marker.from)) || marker.to-marker.from+1, ...(task ? {taskFrom:task.from} : {})}; + result.push(owner); + } + } + if (node.children) visit(node.children,owner,node.kind === "list" ? node : list); + }); + }; + visit(projection.nodes,null,null); + return result; +} +function itemAt(source: string, entries: Item[], offset: number): Item | undefined { + return entries.filter(item => offset >= item.lineFrom && offset <= lineEnd(source,item.node.to)).at(-1); +} +function insideCode(nodes: readonly MarkdownNode[], offset: number): boolean { + return nodes.some(node => offset >= node.from && offset <= node.to && (node.kind === "code" || node.kind === "html" || !!node.children && insideCode(node.children,offset))); +} + +/** Syntax-owned Enter extension used by insertMarkdownParagraph. */ +export function continueMarkdownList(source: string, selection: Selection, projection: MarkdownProjection): MarkdownSourceEdit | null { + const from = Math.min(selection.anchor,selection.focus), to = Math.max(selection.anchor,selection.focus); + if (insideCode(projection.nodes,from)) return null; + const entry = itemAt(source,items(source,projection),from); + if (!entry || from < entry.bodyFrom) return null; + const end = lineEnd(source,from), start = lineStart(source,from); + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + if (from === to && start === entry.lineFrom && !source.slice(entry.bodyFrom,end).trim()) { + // Remove only this list prefix; an enclosing quote remains a quote. + const quote = source.slice(entry.lineFrom,entry.quoteEnd); + const boundary = entry.lineFrom > 0 && !quote ? newline : ""; + const finish = end - (source[end-1] === "\r" ? 1 : 0); + const value = source.slice(0,entry.lineFrom) + boundary + quote + source.slice(finish); + const focus = entry.lineFrom + boundary.length + quote.length; + return {value,selection:{anchor:focus,focus}}; + } + let prefix = source.slice(entry.lineFrom,entry.bodyFrom); + if (entry.taskFrom !== undefined) { + const at = entry.taskFrom - entry.lineFrom; + prefix = prefix.slice(0,at) + "[ ]" + prefix.slice(at+3); + } + const marker = source.slice(entry.markerFrom,entry.markerTo); + if (/^\d+[.)]$/.test(marker)) { + const at = entry.markerFrom - entry.lineFrom; + const next = String(Number(marker.slice(0,-1))+1); + // CommonMark list markers have at most nine digits. + prefix = prefix.slice(0,at) + (next.length <= 9 ? next : "1") + marker.at(-1)! + prefix.slice(at+marker.length); + } + const focus = from + newline.length + prefix.length; + return {value:source.slice(0,from)+newline+prefix+source.slice(to),selection:{anchor:focus,focus}}; +} + +/** Indent/outdent selected sibling items with their descendants; null outside lists. */ +export function indentMarkdownList(source: string, selection: Selection, direction: "indent" | "outdent"): MarkdownSourceEdit | null { + assertMarkdownSelection(source,selection); + const projection = projectMarkdown(source), entries = items(source,projection); + const from = Math.min(selection.anchor,selection.focus), to = Math.max(selection.anchor,selection.focus); + if (insideCode(projection.nodes,from)) return null; + const first = itemAt(source,entries,from), last = itemAt(source,entries,to > from ? to-1 : to); + if (!first || !last) return null; + let a: Item | null = first, b: Item | null = last; + const lineage = (item: Item): Item[] => { const path: Item[] = []; for(let next: Item | null=item;next;next=next.parent) path.push(next); return path; }; + for (const left of lineage(first)) { + const right = lineage(last).find(item => item.list === left.list); + if (right) { a=left; b=right; break; } + } + if (a.list !== b.list) return null; + const selected = entries.filter(item => item.list === a!.list && item.index >= a!.index && item.index <= b!.index); + const previous = entries.find(item => item.list === a!.list && item.index === a!.index-1); + if (direction === "indent" && !previous || direction === "outdent" && !a.parent) return {value:source,selection}; + const width = direction === "indent" ? previous!.contentIndent : a.indentColumns-a.parent!.indentColumns; + if (width <= 0) return {value:source,selection}; + const edits: Array<{from:number; to:number; insert:string}> = []; + const firstLine = selected[0]!.lineFrom, lastEnd = selected.at(-1)!.node.to; + for(let line=firstLine;line<=lastEnd && line[ \t]?)+/.exec(raw)?.[0] ?? ""; + const at=line+quote.length; + if(raw.trim()) { + const leading=/^[ \t]*/.exec(source.slice(at,end))![0]; + const target=Math.max(0,columns(leading)+(direction === "indent" ? width : -width)); + edits.push({from:at,to:at+leading.length,insert:" ".repeat(target)}); + } + if(end===source.length) break; + line=end+1; + } + // A nested ordered list must start at 1 to interrupt the preceding paragraph. + // When promoted, number the moved group immediately after its parent item. + let number = direction === "indent" ? 1 : Number(source.slice(a.parent!.markerFrom,a.parent!.markerTo-1))+1; + if (!Number.isFinite(number) || number>999999999) number=1; + for(const item of selected) { + const marker=source.slice(item.markerFrom,item.markerTo); + if (/^\d+[.)]$/.test(marker)) edits.push({from:item.markerFrom,to:item.markerTo,insert:String(number++)+marker.at(-1)!}); + } + edits.sort((left,right) => left.from-right.from || left.to-right.to); + let value=source; + for(const edit of [...edits].reverse()) value=value.slice(0,edit.from)+edit.insert+value.slice(edit.to); + const map=(offset:number) => { + let delta=0; + for(const edit of edits) { + if(offset < edit.from) break; + if(offset <= edit.to) return edit.from+delta+edit.insert.length; + delta += edit.insert.length-(edit.to-edit.from); + } + return offset+delta; + }; + return {value,selection:{anchor:map(selection.anchor),focus:map(selection.focus)}}; +} diff --git a/packages/json-document-markdown/src/paragraph.ts b/packages/json-document-markdown/src/paragraph.ts index 6651906b..fcf3323d 100644 --- a/packages/json-document-markdown/src/paragraph.ts +++ b/packages/json-document-markdown/src/paragraph.ts @@ -1,3 +1,5 @@ +import { assertMarkdownSelection } from "./source-edit.js"; +import { continueMarkdownList } from "./list-editing.js"; import { projectMarkdown } from "./projection.js"; /** Source-only Enter edit; the caller owns selection application and History. */ @@ -6,11 +8,13 @@ export function insertMarkdownParagraph(source: string, selection: { readonly an readonly selection: { readonly anchor: number; readonly focus: number }; } { const from = Math.min(selection.anchor, selection.focus), to = Math.max(selection.anchor, selection.focus); - if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to > source.length) throw new RangeError("Invalid Markdown selection"); + assertMarkdownSelection(source,selection); const lineFrom = from === 0 ? 0 : source.lastIndexOf("\n", from - 1) + 1; const end = source.indexOf("\n", from); const lineTo = end < 0 ? source.length : end; const projection = projectMarkdown(source); + const list = continueMarkdownList(source, selection, projection); + if (list) return list; const markers = projection.markers.filter(marker => marker.kind === "blockquote" && marker.from >= lineFrom && marker.to <= from); const last = markers.at(-1); const prefixTo = last ? last.to + (/[ \t]/.test(source[last.to] ?? "") ? 1 : 0) : lineFrom; diff --git a/packages/json-document-markdown/src/source-edit.ts b/packages/json-document-markdown/src/source-edit.ts new file mode 100644 index 00000000..05b32fc7 --- /dev/null +++ b/packages/json-document-markdown/src/source-edit.ts @@ -0,0 +1,10 @@ +/** A source-only Markdown edit; the consumer applies it to its existing history. */ +export interface MarkdownSourceEdit { + readonly value: string; + readonly selection: { readonly anchor: number; readonly focus: number }; +} +export function assertMarkdownSelection(source: string, selection: MarkdownSourceEdit["selection"]): void { + for (const offset of [selection.anchor, selection.focus]) { + if (!Number.isInteger(offset) || offset < 0 || offset > source.length) throw new RangeError("Invalid Markdown selection"); + } +} diff --git a/packages/json-document-markdown/tests/list-editing.test.ts b/packages/json-document-markdown/tests/list-editing.test.ts new file mode 100644 index 00000000..02c45dfa --- /dev/null +++ b/packages/json-document-markdown/tests/list-editing.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; +import { indentMarkdownList, insertMarkdownParagraph } from "../src/index.js"; +const enter=(source:string, focus=source.length) => insertMarkdownParagraph(source,{anchor:focus,focus}); +const indent=(source:string, direction:"indent"|"outdent"="indent", anchor=source.length, focus=anchor) => indentMarkdownList(source,{anchor,focus},direction); + +test.each([ + ["- one", "- one\n- "], ["* one", "* one\n* "], ["+ one", "+ one\n+ "], + ["9. one", "9. one\n10. "], ["2) one", "2) one\n3) "], + ["- [x] done", "- [x] done\n- [ ] "], ["3. [X] done", "3. [X] done\n4. [ ] "], + ["- parent\n - child", "- parent\n - child\n - "], + ["> - quote", "> - quote\n> - "], ["- one\r\n- two", "- one\r\n- two\r\n- "], + ["- ", ""], ["- one\n- ", "- one\n\n"], ["> - one\n> - ", "> - one\n> "], + ["- [ ] ", ""], +])("Enter applies parsed list semantics to %s", (source,expected) => expect(enter(source).value).toBe(expected)); + +test("Enter splits list content and ignores list-looking code", () => { + expect(enter("- abcd",4).value).toBe("- ab\n- cd"); + expect(enter("```\n- one\n```",9).value).toBe("```\n- one\n\n```"); + expect(indent("```\n- one\n```","indent",9)).toBeNull(); +}); +test("Tab uses the previous sibling's content indentation and Shift Tab reverses it", () => { + for (const source of ["- one\n- two", "10. one\n11. two", "- [x] one\n- [ ] two", "> - one\n> - two"]) { + const result=indent(source)!; + expect(result.value).not.toBe(source); + const restored=indentMarkdownList(result.value,result.selection,"outdent")!; + expect(restored).toEqual({value:source,selection:{anchor:source.length,focus:source.length}}); + } + expect(indent("10. one\n11. two")?.value).toBe("10. one\n 1. two"); +}); +test("selected siblings move with descendants and preserve backward selection and CRLF", () => { + const source="- one\r\n- two\r\n - child\r\n- three"; + const start=source.indexOf("- two"), end=source.length; + const result=indent(source,"indent",end,start)!; + expect(result.value).toBe("- one\r\n - two\r\n - child\r\n - three"); + expect(result.selection.anchor).toBeGreaterThan(result.selection.focus); + expect(indentMarkdownList(result.value,result.selection,"outdent")?.value).toBe(source); +}); +test("first items and root outdent are consumed without changing source; ordinary paragraphs return null", () => { + expect(indent("- first")?.value).toBe("- first"); + expect(indent("- first","outdent")?.value).toBe("- first"); + expect(indent("plain")).toBeNull(); + expect(() => indent("- item","indent",-1)).toThrow(RangeError); +}); + + +test("Tab accounts for tab-expanded list marker padding", () => { + const result=indent("-\tone\n-\ttwo")!; + expect(result.value).toBe("-\tone\n -\ttwo"); + expect(indentMarkdownList(result.value,result.selection,"outdent")?.value).toBe("-\tone\n-\ttwo"); +}); diff --git a/site/src/routes/markdown-caret/MarkdownCaretRoute.tsx b/site/src/routes/markdown-caret/MarkdownCaretRoute.tsx index ea7b55ec..deb2d7d7 100644 --- a/site/src/routes/markdown-caret/MarkdownCaretRoute.tsx +++ b/site/src/routes/markdown-caret/MarkdownCaretRoute.tsx @@ -17,7 +17,7 @@ export function MarkdownCaretRoute() { const snapshot = useEditingSnapshot(editor); return - 제목의 공백은 입력한 만큼 표시되며 ←로 한 칸씩 이동하면 왼쪽 H 표시에 도달합니다. # 입력·Backspace·Delete로 제목 단계를 편집할 수 있습니다. 불릿·번호·인용·펜스도 같은 기호 경계로 이동하고 원문을 편집합니다. 체크박스는 클릭·Space로 체크하고 Delete·Backspace 한 번으로 지웁니다. ↑↓는 보이는 줄을 따라 같은 가로 위치로 이동하며 Shift를 누르면 선택을 확장합니다. 인용문에서는 Enter로 이어 쓰고 빈 줄에서 Enter로 빠져나옵니다. Undo 한 번이면 복원됩니다. 강조·링크·표는 선택하면 문법이 드러납니다. 선택·복사와 ⌘/Ctrl+Z를 시험해 보세요. + 제목의 공백은 입력한 만큼 표시되며 ←로 한 칸씩 이동하면 왼쪽 H 표시에 도달합니다. # 입력·Backspace·Delete로 제목 단계를 편집할 수 있습니다. 불릿·번호·인용·펜스도 같은 기호 경계로 이동하고 원문을 편집합니다. 체크박스는 클릭·Space로 체크하고 Delete·Backspace 한 번으로 지웁니다. ↑↓는 보이는 줄을 따라 같은 가로 위치로 이동하며 Shift를 누르면 선택을 확장합니다. 목록과 인용문은 Enter로 이어 쓰고 빈 항목에서 Enter로 빠져나옵니다. 목록의 Tab/Shift+Tab은 선택한 항목과 하위 내용을 함께 들여쓰기·내어쓰기 합니다. Undo 한 번이면 복원됩니다. 강조·링크·표는 선택하면 문법이 드러납니다. 선택·복사와 ⌘/Ctrl+Z를 시험해 보세요. }>
diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index bb1b7083..4745a0e3 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -1,3 +1,6 @@ +import markdownSourceEditSource from "../../../../packages/json-document-markdown/src/source-edit.ts?raw"; +import markdownListEditingSource from "../../../../packages/json-document-markdown/src/list-editing.ts?raw"; +import markdownBindingSource from "../../../../packages/json-document-markdown-web/src/editing-binding.ts?raw"; import textNavigationSource from "../../../../packages/json-document-contenteditable/src/dom/text-navigation.ts?raw"; import caretVisibilitySource from "../../../../packages/json-document-contenteditable/src/dom/caret-visibility.ts?raw"; import markdownParagraphSource from "../../../../packages/json-document-markdown/src/paragraph.ts?raw"; @@ -287,7 +290,10 @@ const registeredUsageSources = new Map([ ["packages/json-document-animation-react/src/animations.tsx", animationSource], ["packages/json-document-markdown-react/src/MarkdownRenderer.tsx", markdownRendererSource], ["packages/json-document-markdown-react/src/MarkdownEditingSurface.tsx", markdownEditingSource], + ["packages/json-document-markdown-web/src/editing-binding.ts", markdownBindingSource], ["packages/json-document-markdown/src/paragraph.ts", markdownParagraphSource], + ["packages/json-document-markdown/src/list-editing.ts", markdownListEditingSource], + ["packages/json-document-markdown/src/source-edit.ts", markdownSourceEditSource], ["packages/json-document-contenteditable/src/dom/text-navigation.ts", textNavigationSource], ["packages/json-document-contenteditable/src/dom/caret-visibility.ts", caretVisibilitySource], ["packages/json-document-markdown/src/nodes.ts", markdownNodesSource], @@ -424,6 +430,8 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredImplementationSources = new Map>([ + ["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"]], ["packages/json-document-editing/src/text.ts", ["packages/json-document-editing/src/session.ts"]], ["packages/json-document-editing/src/session.ts", ["packages/json-document-editing/src/history-patch.ts"]], @@ -811,6 +819,27 @@ const registeredPublicUsages = [ sourcePath: "packages/json-document-markdown/src/syntax.ts", }, { packageName: "@interactive-os/json-document-markdown", symbol: "createMarkdownParser", sourcePath: "packages/json-document-markdown/src/nodes.ts" }, + { + packageName: "@interactive-os/json-document-markdown-web", + symbol: "createMarkdownEditingBinding", + sourcePath: "packages/json-document-markdown-web/src/editing-binding.ts", + }, + { + packageName: "@interactive-os/json-document-markdown-web", + symbol: "createMarkdownEditingBinding", + sourcePath: "packages/json-document-markdown-web/src/markdown-dom.ts", + }, + { + packageName: "@interactive-os/json-document-markdown-web", + symbol: "createMarkdownEditingBinding", + sourcePath: "packages/json-document-markdown-web/src/source-runs.ts", + }, + { + packageName: "@interactive-os/json-document-markdown-web", + symbol: "createMarkdownEditingBinding", + sourcePath: "packages/json-document-markdown-web/src/markdown-editor.css", + }, + { packageName: "@interactive-os/json-document-markdown", symbol: "indentMarkdownList", sourcePath: "packages/json-document-markdown/src/list-editing.ts" }, { packageName: "@interactive-os/json-document-markdown", symbol: "insertMarkdownParagraph", sourcePath: "packages/json-document-markdown/src/paragraph.ts" }, { packageName: "@interactive-os/json-document-markdown", symbol: "setMarkdownTaskChecked", sourcePath: "packages/json-document-markdown/src/tasks.ts" }, ...["createMarkdownParser", "projectMarkdown", "MarkdownMarker", "MarkdownMarkerKind"].map(symbol => ({ diff --git a/site/tests/browser/markdown-lists.spec.ts b/site/tests/browser/markdown-lists.spec.ts new file mode 100644 index 00000000..51bbb20e --- /dev/null +++ b/site/tests/browser/markdown-lists.spec.ts @@ -0,0 +1,53 @@ +import { expect, test, type Locator } from "@playwright/test"; +async function source(editor: Locator, value: string) { + await editor.click(); await editor.press("ControlOrMeta+a"); + await editor.evaluate((root,value) => { const data=new DataTransfer(); data.setData("text/plain",value); root.dispatchEvent(new ClipboardEvent("paste",{clipboardData:data,bubbles:true,cancelable:true})); },value); + await expect.poll(()=>editor.textContent()).toBe(value); +} +async function select(editor: Locator, anchor:number, focus=anchor) { + await editor.evaluate((root,{anchor,focus}) => { + root.focus(); + const at=(offset:number) => { const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT); let node:Node|null; while((node=walker.nextNode())) { const length=node.textContent!.length; if(offset<=length) return {node,offset}; offset-=length; } throw Error("source offset missing"); }; + const a=at(anchor), f=at(focus); document.getSelection()!.setBaseAndExtent(a.node,a.offset,f.node,f.offset); document.dispatchEvent(new Event("selectionchange")); + },{anchor,focus}); +} +for (const [prefix,next,nested] of [["- ","- "," - "],["1. ","2. "," 1. "],["- [x] ","- [ ] "," - [ ] "]] as const) { + test(`${prefix} Enter, Tab and Shift Tab use one list command with history`, async ({page}) => { + await page.goto("/applications/bear"); const editor=page.getByRole("textbox",{name:"Markdown 문서"}); + await source(editor,prefix+"first"); await page.keyboard.press("Enter"); + await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+next); + await page.keyboard.insertText("second"); await page.keyboard.press("Tab"); + await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+nested+"second"); + await expect(editor.locator('[data-markdown-kind="list"] [data-markdown-kind="list"]')).toHaveCount(1); + await page.keyboard.press("Shift+Tab"); + await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+next+"second"); + await page.keyboard.press("ControlOrMeta+z"); + await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+nested+"second"); + await page.keyboard.press("ControlOrMeta+Shift+z"); + await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+next+"second"); + await page.keyboard.press("Enter"); await page.keyboard.press("Enter"); + await expect.poll(()=>editor.textContent()).toBe(prefix+"first\n"+next+"second\n\n"); + await page.keyboard.insertText("outside"); + await expect(editor.locator('[data-markdown-kind="list"]')).not.toContainText("outside"); + }); +} +test("backward selection indents sibling items and their child together", async ({page}) => { + await page.goto("/applications/bear"); const editor=page.getByRole("textbox",{name:"Markdown 문서"}); + const original="- one\n- two\n - child\n- three"; + await source(editor,original); await select(editor,original.length,original.indexOf("- two")); + await page.keyboard.press("Tab"); + await expect.poll(()=>editor.textContent()).toBe("- one\n - two\n - child\n - three"); + expect(await editor.evaluate(()=>document.getSelection()!.anchorOffset !== document.getSelection()!.focusOffset || document.getSelection()!.anchorNode !== document.getSelection()!.focusNode)).toBe(true); + await page.keyboard.press("Shift+Tab"); await expect.poll(()=>editor.textContent()).toBe(original); +}); +test("ordinary text Tab releases focus and selected list syntax highlights its projection", async ({page}) => { + await page.goto("/applications/bear"); const editor=page.getByRole("textbox",{name:"Markdown 문서"}); + await source(editor,"- one"); await select(editor,1); await page.keyboard.press("Shift+ArrowLeft"); + const marker=editor.locator('[data-markdown-marker="list"]'); + await expect(marker).toHaveAttribute("data-text-projection-selected", ""); + expect(await marker.locator('[data-text-projection-source]').evaluate(element=>getComputedStyle(element,"::selection").backgroundColor)).toBe("rgba(0, 0, 0, 0)"); + expect(await editor.evaluate(()=>document.getSelection()!.toString())).toBe("-"); + await page.screenshot({path:test.info().outputPath("list-selection.png")}); + await source(editor,"plain"); await page.keyboard.press("Tab"); await expect(editor).not.toBeFocused(); + await expect.poll(()=>editor.textContent()).toBe("plain"); +}); diff --git a/site/tests/browser/markdown-markers.spec.ts b/site/tests/browser/markdown-markers.spec.ts index dc8f0cea..ad605980 100644 --- a/site/tests/browser/markdown-markers.spec.ts +++ b/site/tests/browser/markdown-markers.spec.ts @@ -85,9 +85,19 @@ test("reading presentation retains code text, table rows and one thematic rule", }); -test("quote Enter continues the box, exits on empty content, and restores with Undo", async ({page}) => { +for (const direct of [false, true]) test(`quote Enter continues the box, exits on empty content, and restores with Undo (direct Web: ${direct})`, async ({page}) => { await page.goto("/applications/bear"); - const editor = page.getByRole("textbox", {name:"Markdown 문서"}); + if (direct) await page.evaluate(async packageRoot => { + const {createMarkdownEditingBinding} = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document-markdown-web/src/index.ts`); + const {createJSONDocument} = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document/src/application/document/index.ts`); + const {createTextEditor} = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document-editing/src/index.ts`); + const root = document.createElement("div"); root.contentEditable = "true"; + root.setAttribute("role", "textbox"); root.setAttribute("aria-label", "직접 Web 편집"); + root.style.cssText="position:fixed;inset:20px;z-index:9999;background:white;white-space:pre-wrap"; + document.body.append(root); + createMarkdownEditingBinding({editor:createTextEditor(createJSONDocument("")), root}).bind(); + }, new URL("../../../packages/", import.meta.url).pathname); + const editor = page.getByRole("textbox", {name:direct ? "직접 Web 편집" : "Markdown 문서"}); await editor.click(); await page.keyboard.press("ControlOrMeta+a"); await editor.evaluate(root => { diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 4bd36630..a38c87b0 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -414,6 +414,8 @@ test("Markdown Usage exposes the canonical text projection and restoration sourc "packages/json-document-markdown/src/markers.ts", "packages/json-document-markdown/src/tasks.ts", "packages/json-document-markdown/src/paragraph.ts", + "packages/json-document-markdown/src/list-editing.ts", + "packages/json-document-markdown-web/src/editing-binding.ts", "packages/json-document-contenteditable/src/dom/text-projection.ts", "packages/json-document-contenteditable/src/dom/text-projection.css", "packages/json-document-contenteditable/src/dom/plain-text.ts",