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
50 changes: 48 additions & 2 deletions docs/api-reference/contenteditable.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ 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<TextSelection>;
}
```
## `ContentEditableBindingResult`
Expand All @@ -53,7 +55,17 @@ interface ContentEditableProps {
## `createContentEditableBinding`

```ts
createContentEditableBinding({ document, dom, pointer, root, editor, }: ContentEditableBindingOptions): ContentEditableBinding
createContentEditableBinding({ document, dom, pointer, root, editor, insertBreak, }: ContentEditableBindingOptions): ContentEditableBinding
```
## `createTextNavigationDOMAdapter`

```ts
createTextNavigationDOMAdapter(base: TextDOMAdapter): TextDOMAdapter
```
## `createTextProjectionDOMAdapter`

```ts
createTextProjectionDOMAdapter(base: TextDOMAdapter, projections: (root: HTMLElement) => ReadonlyArray<TextProjection>): TextDOMAdapter
```
## `DOMObservation`

Expand All @@ -73,13 +85,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`
Expand Down
6 changes: 6 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,9 @@ 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에 남습니다.

수평 투영 adapter는 자신이 처리하지 않는 기존 capability를 보존합니다. 따라서
`createTextProjectionDOMAdapter(createTextNavigationDOMAdapter(base), projections)`도
수직 이동과 `resetNavigation`을 유지합니다. 두 adapter의 `restoreSelection`은 바깥쪽에서
안쪽으로 위임되므로 순서에 따라 투영 affinity·caret 표시의 적용 순서는 달라질 수 있습니다.
Markdown Web은 기존처럼 navigation이 projection을 감싸는 조합을 사용합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function createTextProjectionDOMAdapter(
if (root.hasAttribute("data-text-projection-caret") !== active) root.toggleAttribute("data-text-projection-caret", active);
};
return {
...base,
observe: root => base.observe(root),
render(root, value, selection = null) {
base.render(root, value, selection);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,21 @@ test("plain source rendering retains the live text node when only selection chan
plainTextDOMAdapter.render(root, "changed");
expect(root.textContent).toBe("changed");
});


test("projection preserves vertical navigation and its reset lifecycle", () => {
const {root} = fixture();
let goal: number | null = null;
const base: TextDOMAdapter = {...plainTextDOMAdapter,
resolveVerticalSelection(_root, selection) {
goal ??= selection.focus;
return {anchor:goal, focus:goal};
},
resetNavigation() { goal = null; },
};
const adapter = createTextProjectionDOMAdapter(base, () => []);
expect(adapter.resolveVerticalSelection!(root, {anchor:3,focus:3}, "forward", false)?.focus).toBe(3);
expect(adapter.resolveVerticalSelection!(root, {anchor:1,focus:1}, "forward", false)?.focus).toBe(3);
adapter.resetNavigation!(root);
expect(adapter.resolveVerticalSelection!(root, {anchor:1,focus:1}, "forward", false)?.focus).toBe(1);
});
13 changes: 9 additions & 4 deletions site/tests/browser/markdown-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ test("blank terminal lines remain reachable and pointer placement resets the hor
await expect(editor).toHaveText(source + "end");
});

test("the public navigation adapter handles 1000 plain lines and reveals a caret inside a scroll container", async ({page}) => {
for (const outerProjection of [false, true]) test(`the public navigation adapter handles 1000 plain lines (outer projection: ${outerProjection})`, async ({page}) => {
await page.goto("/applications/bear");
const packageRoot = new URL("../../../packages/", import.meta.url).pathname;
await page.evaluate(async packageRoot => {
await page.evaluate(async ({packageRoot, outerProjection}) => {
const contenteditable = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document-contenteditable/src/index.ts`);
const core = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document/src/application/document/index.ts`);
const editing = await import(/* @vite-ignore */ `/@fs${packageRoot}json-document-editing/src/index.ts`);
Expand All @@ -126,10 +126,11 @@ test("the public navigation adapter handles 1000 plain lines and reveals a caret
root.style.cssText="position:fixed;top:20px;left:20px;width:360px;height:160px;overflow:auto;white-space:pre-wrap;line-height:28px;font:18px/28px monospace;background:white;z-index:9999";
document.body.append(root);
const model = core.createJSONDocument(source), editor = editing.createTextEditor(model);
const dom = contenteditable.createTextNavigationDOMAdapter(contenteditable.plainTextDOMAdapter);
const navigation = contenteditable.createTextNavigationDOMAdapter(contenteditable.plainTextDOMAdapter);
const dom = outerProjection ? contenteditable.createTextProjectionDOMAdapter(navigation, () => []) : navigation;
contenteditable.createContentEditableBinding({document:model,pointer:"",editor,root,dom}).bind();
root.focus(); editor.select({anchor:7,focus:7});
}, packageRoot);
}, {packageRoot, outerProjection});
const editor = page.getByTestId("generic-navigation");
for(let i=0;i<22;i++) await page.keyboard.press("ArrowDown");
expect(await editor.evaluate(root=>root.scrollTop)).toBeGreaterThan(200);
Expand All @@ -138,4 +139,8 @@ test("the public navigation adapter handles 1000 plain lines and reveals a caret
expect(p.y+p.height).toBeLessThanOrEqual(box!.y+box!.height+1);
for(let i=0;i<22;i++) await page.keyboard.press("ArrowUp");
expect((await point(editor)).focus).toBe(7);
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowUp");
expect((await point(editor)).focus).toBe(6);
});