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
52 changes: 50 additions & 2 deletions docs/api-reference/contenteditable.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextSelection>;
/** Syntax-owned Tab action; null leaves native focus navigation available. */
readonly indent?: (editor: TextEditor, direction: "indent" | "outdent") => EditingResult<TextSelection> | null;
}
```
## `ContentEditableBindingResult`
Expand All @@ -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<TextProjection>): TextDOMAdapter
```
## `DOMObservation`

Expand All @@ -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`
Expand Down
23 changes: 22 additions & 1 deletion docs/api-reference/markdown-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
```
40 changes: 40 additions & 0 deletions docs/api-reference/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -62,6 +88,15 @@ interface MarkdownProjection {
readonly source: string;
readonly nodes: ReadonlyArray<MarkdownNode>;
readonly strong: ReadonlyArray<MarkdownStrongSpan>;
readonly markers: ReadonlyArray<MarkdownMarker>;
}
```
## `MarkdownSourceEdit`

```ts
interface MarkdownSourceEdit {
readonly value: string;
readonly selection: { readonly anchor: number; readonly focus: number };
}
```
## `MarkdownStrongSpan`
Expand All @@ -87,3 +122,8 @@ interface MarkdownUpdate {
```ts
projectMarkdown(source: string): MarkdownProjection
```
## `setMarkdownTaskChecked`

```ts
setMarkdownTaskChecked(source: string, from: number, checked: boolean): string
```
12 changes: 3 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 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,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은 그대로 유지합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/json-document-contenteditable/src/lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/json-document-contenteditable/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextSelection>;
/** Syntax-owned Tab action; null leaves native focus navigation available. */
readonly indent?: (editor: TextEditor, direction: "indent" | "outdent") => EditingResult<TextSelection> | null;
}

export type ContentEditableBindingResult =
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("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");
});
6 changes: 3 additions & 3 deletions packages/json-document-markdown-react/docs/editing.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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와 원문 선택을 유지합니다.
8 changes: 2 additions & 6 deletions packages/json-document-markdown-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": "*"
}
}
Original file line number Diff line number Diff line change
@@ -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<HTMLAttributes<HTMLDivElement>, "children" | "contentEditable"> {
readonly editor: TextEditor;
Expand All @@ -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 <div {...props} ref={rootRef} role="textbox" aria-multiline="true" contentEditable suppressContentEditableWarning style={{ ...style, whiteSpace: "pre-wrap", overflowWrap: "anywhere" }} />;
Expand Down
10 changes: 6 additions & 4 deletions packages/json-document-markdown-react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Loading