diff --git a/packages/json-document-contenteditable/docs/editing.md b/packages/json-document-contenteditable/docs/editing.md index 299a56b6..785487f6 100644 --- a/packages/json-document-contenteditable/docs/editing.md +++ b/packages/json-document-contenteditable/docs/editing.md @@ -154,3 +154,8 @@ 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에 남습니다. + +`TextProjection.atomic`은 표시 구간과 `following`까지의 구분 공백을 하나의 이동·삭제 단위로 취급합니다. +←/→는 양 끝만 방문하고 Shift 이동은 원문 구간 전체를 선택합니다. Backspace/Delete는 +해당 단위를 한 번에 제거하며 기존 원문 offset·복사·Undo 계약은 유지합니다. +비원자 투영은 `from`, `to`, `following`의 기존 정지점을 유지합니다. diff --git a/packages/json-document-contenteditable/src/dom/text-projection.ts b/packages/json-document-contenteditable/src/dom/text-projection.ts index cf412258..c6dd0db5 100644 --- a/packages/json-document-contenteditable/src/dom/text-projection.ts +++ b/packages/json-document-contenteditable/src/dom/text-projection.ts @@ -7,7 +7,7 @@ export interface TextProjection { 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. */ + /** Treat the displayed unit and its separator as one navigation and deletion unit. */ readonly atomic?: boolean; } @@ -75,7 +75,7 @@ export function createTextProjectionDOMAdapter( const end = region.following ?? region.to; if (focus < region.from || focus > end) continue; if (!extend && !collapsed) return {anchor: focus, focus}; - const stops = [region.from, region.to, end]; + const stops = region.atomic ? [region.from, end] : [region.from, region.to, end]; const next = direction === "backward" ? stops.filter(offset => offset < focus).at(-1) : stops.find(offset => offset > focus); diff --git a/packages/json-document-contenteditable/tests/text-projection.test.ts b/packages/json-document-contenteditable/tests/text-projection.test.ts index daa09083..39238ca2 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("atomic horizontal movement includes its separator without an interior stop", () => { + const {root, marker} = fixture(); + const adapter = createTextProjectionDOMAdapter(plainTextDOMAdapter, () => [{from:0,to:7,following:8,atomic:true,element:marker}]); + expect(adapter.resolveHorizontalSelection!(root,{anchor:0,focus:0},"forward",false)).toEqual({anchor:8,focus:8}); + expect(adapter.resolveHorizontalSelection!(root,{anchor:8,focus:8},"backward",false)).toEqual({anchor:0,focus:0}); + expect(adapter.resolveHorizontalSelection!(root,{anchor:0,focus:0},"forward",true)).toEqual({anchor:0,focus:8}); + expect(adapter.resolveHorizontalSelection!(root,{anchor:8,focus:8},"backward",true)).toEqual({anchor:8,focus:0}); + expect(adapter.resolveHorizontalSelection!(root,{anchor:0,focus:8},"forward",false)).toEqual({anchor:8,focus:8}); +}); diff --git a/packages/json-document-markdown-web/docs/api.md b/packages/json-document-markdown-web/docs/api.md index 5c6bd206..df1e9739 100644 --- a/packages/json-document-markdown-web/docs/api.md +++ b/packages/json-document-markdown-web/docs/api.md @@ -112,3 +112,9 @@ Markdown 문자열이 정본이고 CommonMark + GFM은 문법 의미를 결정 ## 보이는 줄을 따르는 수직 이동 `createMarkdownDOMAdapter`는 contenteditable의 공개 `createTextNavigationDOMAdapter`와 `createTextProjectionDOMAdapter`를 조합합니다. ↑↓는 숨긴 원문 기호 대신 보이는 본문 줄을 따라 가로 위치를 유지하고, Shift는 원래 anchor를 유지합니다. 별도의 Markdown 문법별 방향키 분기는 없습니다. [공용 화면 줄 이동 계약](/docs/api/contenteditable)과 [Usage](/demo/markdown-caret)에서 문서 끝·빈 줄·스크롤·native fallback의 범위를 확인할 수 있습니다. + +가로줄(`---`, `***`, `___`와 공백을 포함한 동등 문법)과 todo는 생성된 뒤 하나의 컨트롤입니다. +Markdown Web이 공용 `TextProjection.atomic`을 지정하므로 ←/→ 한 번으로 지나가고, +Shift 이동은 전체 원문을 선택하며 Backspace/Delete 한 번으로 지웁니다. todo는 접두사와 +첫 구분 공백까지 포함하고 추가 본문 공백은 보존합니다. 가로줄은 개행을 포함하지 않습니다. +복사와 저장은 Markdown 원문 그대로이며 Undo/Redo는 기존 Editing History를 사용합니다. diff --git a/packages/json-document-markdown-web/src/source-runs.ts b/packages/json-document-markdown-web/src/source-runs.ts index 77237a87..52f0da43 100644 --- a/packages/json-document-markdown-web/src/source-runs.ts +++ b/packages/json-document-markdown-web/src/source-runs.ts @@ -50,8 +50,9 @@ export function sourceRuns(projection: MarkdownProjection): SourceRun[] { const projected = (marker: MarkdownMarker, parent?: MarkdownNode, owner?: SourceRange, conceal = false): SourceRun => { const {from, kind} = marker; const syntaxTo = marker.to; - const atomic = kind === "task" || kind === "blockquote"; - const to = atomic && /[ \t]/.test(source[syntaxTo] ?? "") ? syntaxTo + 1 : syntaxTo; + const prefix = kind === "task" || kind === "blockquote"; + const atomic = prefix || kind === "thematicBreak"; + const to = prefix && /[ \t]/.test(source[syntaxTo] ?? "") ? syntaxTo + 1 : syntaxTo; const original = source.slice(from, to); const heading = kind === "heading" && parent?.kind === "heading" && from === parent.from; const label = marker.value ?? (heading ? `H${parent.depth}` : kind === "list" ? (/^\d/.test(original) ? original.replace(/\)$/, ".") : "•") 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 13c3111a..b9b8bc64 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,17 @@ 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.each(["---", "***", "___", "* * *", "- - -", "--- ", "- [ ] item", "12. [x] item"])("rendered control is one source-preserving atom: %s", source => { + const {root,dom} = setup(source + "\n\nnext"); + const marker = root.querySelector('[data-markdown-marker="thematicBreak"], [data-markdown-marker="task"]')!; + const prefix = document.createRange(); prefix.selectNodeContents(root); prefix.setEndBefore(marker); + const from = prefix.toString().length, to = from + marker.textContent!.length; + expect(dom.resolveHorizontalSelection!(root,{anchor:from,focus:from},"forward",true)).toEqual({anchor:from,focus:to}); + expect(dom.resolveHorizontalSelection!(root,{anchor:to,focus:to},"backward",false)).toEqual({anchor:from,focus:from}); + expect(dom.resolveDeletionSelection!(root,{anchor:to,focus:to},"backward")).toEqual({anchor:from,focus:to}); + expect(dom.resolveDeletionSelection!(root,{anchor:from,focus:from},"forward")).toEqual({anchor:from,focus:to}); + expect(dom.resolveDeletionSelection!(root,{anchor:from+1,focus:to-1},"forward")).toEqual({anchor:from,focus:to}); + expect(dom.observe(root).value).toBe(source + "\n\nnext"); +}); diff --git a/site/src/routes/markdown-caret/MarkdownCaretRoute.tsx b/site/src/routes/markdown-caret/MarkdownCaretRoute.tsx index ea7b55ec..fcad03c8 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로 체크합니다. 가로줄과 체크박스는 한 글자처럼 ←/→ 한 번으로 지나가고 Shift로 통째로 선택하며 Delete·Backspace 한 번으로 지웁니다. ↑↓는 보이는 줄을 따라 같은 가로 위치로 이동하며 Shift를 누르면 선택을 확장합니다. 인용문에서는 Enter로 이어 쓰고 빈 줄에서 Enter로 빠져나옵니다. Undo 한 번이면 복원됩니다. 강조·링크·표는 선택하면 문법이 드러납니다. 선택·복사와 ⌘/Ctrl+Z를 시험해 보세요. }>
diff --git a/site/tests/browser/markdown-markers.spec.ts b/site/tests/browser/markdown-markers.spec.ts index dc8f0cea..68d70216 100644 --- a/site/tests/browser/markdown-markers.spec.ts +++ b/site/tests/browser/markdown-markers.spec.ts @@ -10,7 +10,9 @@ const fixtures = [ ["link", "a [label](/path) z", "link"], ["image", "a ![alt](/missing.png) z", "image"], ["reference", "[ref][a]\n\n[a]: /path", "link"], ["autolink", "", "link"], ["definition", "[a]: /path", "definition"], ["table", "| a | b |\n| --- | --- |\n| c | d |", "table"], - ["rule", "---", "thematicBreak"], ["setext", "title\n===", "setext"], + ["rule", "---", "thematicBreak"], ["star rule", "***", "thematicBreak"], + ["underscore rule", "___", "thematicBreak"], ["spaced rule", "* * *", "thematicBreak"], + ["trailing rule", "--- ", "thematicBreak"], ["ordered task", "12. [x] item", "task"], ["setext", "title\n===", "setext"], ["hard break", "a\\\nb", "break"], ["space break", "a \nb", "break"], ["footnote", "Note[^a]\n\n[^a]: Footnote", "footnote"], ["entity", "a & b", "entity"], ["numeric entity", "a 😀 b", "entity"], @@ -50,15 +52,21 @@ for (const [name, markdown, kind] of fixtures) test(`${name} projection keeps so const data=new DataTransfer();root.dispatchEvent(new ClipboardEvent("copy",{clipboardData:data,bubbles:true,cancelable:true}));return data.getData("text/plain"); }); expect(copied).toBe(source.slice(bounds.from,bounds.to)); + if (kind === "task" || kind === "thematicBreak") { + await page.keyboard.insertText("x"); + await expect.poll(()=>editor.textContent()).toBe(source.slice(0,bounds.from)+"x"+source.slice(bounds.to)); + await page.keyboard.press("ControlOrMeta+z"); + await expect.poll(()=>editor.textContent()).toBe(source); + } await page.keyboard.press("ArrowRight"); await page.keyboard.press("Backspace"); - await expect.poll(()=>editor.textContent()).toBe((kind === "task" || kind === "blockquote") ? source.slice(0,bounds.from)+source.slice(bounds.to) : source.slice(0,bounds.to-1)+source.slice(bounds.to)); + await expect.poll(()=>editor.textContent()).toBe((kind === "task" || kind === "blockquote" || kind === "thematicBreak") ? source.slice(0,bounds.from)+source.slice(bounds.to) : source.slice(0,bounds.to-1)+source.slice(bounds.to)); await page.keyboard.press("ControlOrMeta+z"); await expect.poll(()=>editor.textContent()).toBe(source); await expect.poll(position).toBe(bounds.to); await page.keyboard.press("ArrowLeft"); await page.keyboard.press("Delete"); - await expect.poll(()=>editor.textContent()).toBe((kind === "task" || kind === "blockquote") ? source.slice(0,bounds.from)+source.slice(bounds.to) : source.slice(0,bounds.from)+source.slice(bounds.from+1)); + await expect.poll(()=>editor.textContent()).toBe((kind === "task" || kind === "blockquote" || kind === "thematicBreak") ? source.slice(0,bounds.from)+source.slice(bounds.to) : source.slice(0,bounds.from)+source.slice(bounds.from+1)); await page.keyboard.press("ControlOrMeta+z"); await expect.poll(()=>editor.textContent()).toBe(source); await page.keyboard.insertText("x"); @@ -141,3 +149,18 @@ test("native quote entry preserves its DOM side and vertical goal through select await expect.poll(position).toBe(7); await expect.poll(() => editor.textContent()).toBe(source); }); + + +test("typing a rule creates one control whose source is removed by one Backspace", async ({page}) => { + await page.goto("/applications/bear"); + const editor = page.getByRole("textbox", {name:"Markdown 문서"}); + await editor.click(); await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type("---"); + await expect(editor.locator('[data-markdown-marker="thematicBreak"]')).toHaveCount(1); + await page.keyboard.press("Backspace"); + await expect.poll(()=>editor.textContent()).toBe(""); + await page.keyboard.press("ControlOrMeta+z"); + await expect.poll(()=>editor.textContent()).toBe("---"); + await expect(editor.locator('[data-markdown-marker="thematicBreak"]')).toHaveCount(1); +});