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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/json-document-contenteditable/docs/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`의 기존 정지점을 유지합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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});
});
6 changes: 6 additions & 0 deletions packages/json-document-markdown-web/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 사용합니다.
5 changes: 3 additions & 2 deletions packages/json-document-markdown-web/src/source-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\)$/, ".") : "•")
Expand Down
14 changes: 14 additions & 0 deletions packages/json-document-markdown-web/tests/markdown-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
2 changes: 1 addition & 1 deletion site/src/routes/markdown-caret/MarkdownCaretRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function MarkdownCaretRoute() {
const snapshot = useEditingSnapshot(editor);
return <DemoPage documentation={
<PageHeader title="Markdown 원문을 직접 편집합니다.">
제목의 공백은 입력한 만큼 표시되며 ←로 한 칸씩 이동하면 왼쪽 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를 시험해 보세요.
</PageHeader>
}>
<div className="mx-auto grid w-full max-w-3xl gap-8 py-6">
Expand Down
29 changes: 26 additions & 3 deletions site/tests/browser/markdown-markers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<https://example.com>", "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 &amp; b", "entity"], ["numeric entity", "a &#x1f600; b", "entity"],
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});