Skip to content
Merged
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 docs/api-reference/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ gridBoundary(topology: GridTopology, point: GridPoint, edge: "start" | "end"): G
```ts
isWebEditableTarget(target: object | null): boolean
```
## `isWebEditingHostTarget`

```ts
isWebEditingHostTarget(root: object, target: object | null): boolean
```
## `kanbanCardDropTargetFromWebElement`

```ts
Expand Down
6 changes: 5 additions & 1 deletion 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/contenteditable-collaboration/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# json-document-contenteditable-collaboration

Editing-host ownership is supplied by the Web peer's
`isWebEditingHostTarget` capability. This Draft revision must be consumed with
that Web revision; causal capture/plan/commit and wire formats are unchanged.

IME-safe native-input DOM lease for
`@interactive-os/json-document-collaboration/text`.

Expand Down
2 changes: 2 additions & 0 deletions packages/contenteditable-collaboration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,13 @@
},
"peerDependencies": {
"@interactive-os/json-document": "^3.0.0",
"@interactive-os/json-document-web": "^0.1.0-rc.0",
"@interactive-os/json-document-collaboration": "^0.2.0-rc.1",
"@interactive-os/json-document-contenteditable": "^0.1.0-rc.0"
},
"devDependencies": {
"@interactive-os/json-document": "*",
"@interactive-os/json-document-web": "*",
"@interactive-os/json-document-collaboration": "*",
"@interactive-os/json-document-contenteditable": "*",
"@types/node": "^25.9.0",
Expand Down
46 changes: 2 additions & 44 deletions packages/contenteditable-collaboration/src/lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
TextSelection,
} from "@interactive-os/json-document-collaboration/text";
import { plainTextDOMAdapter } from "@interactive-os/json-document-contenteditable";
import { isWebEditingHostTarget } from "@interactive-os/json-document-web";
import type {
ContentEditableAdapter,
ContentEditableOptions,
Expand Down Expand Up @@ -309,7 +310,7 @@ export function createContentEditableAdapter({
};

const boundHandle = (event: Event): void => {
if (!eventBelongsToRoot(event, root)) return;
if (!isWebEditingHostTarget(root, event.target)) return;
report(handleInternal(event));
};

Expand Down Expand Up @@ -363,49 +364,6 @@ export function createContentEditableAdapter({
});
}

function eventBelongsToRoot(event: Event, root: HTMLElement): boolean {
const target = event.target;
if (!(target instanceof root.ownerDocument.defaultView!.Node)) return false;
if (target === root) return true;
if (!root.contains(target)) return false;

let element = target instanceof root.ownerDocument.defaultView!.Element
? target
: target.parentElement;
while (element !== null && element !== root) {
const tag = element.tagName.toLowerCase();
if (
tag === "input"
|| tag === "textarea"
|| tag === "select"
|| tag === "option"
) {
return false;
}
const editable = element.getAttribute("contenteditable");
const editableProperty = "contentEditable" in element
&& typeof element.contentEditable === "string"
? element.contentEditable.toLowerCase()
: "";
if (
(editable !== null || editableProperty !== "")
&& (
editable === ""
|| editable?.toLowerCase() === "true"
|| editable?.toLowerCase() === "plaintext-only"
|| editable?.toLowerCase() === "false"
|| editableProperty === "true"
|| editableProperty === "plaintext-only"
|| editableProperty === "false"
)
) {
return false;
}
element = element.parentElement;
}
return true;
}

function isCompositionInput(event: Event): boolean {
const inputType = "inputType" in event
&& typeof event.inputType === "string"
Expand Down
1 change: 1 addition & 0 deletions packages/contenteditable-collaboration/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{ "path": "../json-document-web" },
{ "path": "../json-document" },
{ "path": "../json-document-collaboration" },
{ "path": "../json-document-contenteditable" }
Expand Down
5 changes: 5 additions & 0 deletions packages/json-document-contenteditable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ immediately and cannot replace the leased root. Release renders the latest
string at the bound pointer.

Removing this package does not change canonical JSON or Editing semantics.

Composition-tail input and timer completion both publish the latest model,
including changes received just after compositionend. They never author a
second composition commit. Native nested controls retain their own input.
This Draft revision requires the Web peer's `isWebEditingHostTarget` capability.
2 changes: 2 additions & 0 deletions packages/json-document-contenteditable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@
},
"peerDependencies": {
"@interactive-os/json-document": "^3.0.0",
"@interactive-os/json-document-web": "^0.1.0-rc.0",
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@interactive-os/json-document": "*",
"@interactive-os/json-document-web": "*",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.9.0",
"@types/react": "^19.2.14",
Expand Down
63 changes: 11 additions & 52 deletions packages/json-document-contenteditable/src/lease.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { plainTextDOMAdapter } from "./dom/plain-text.js";
import { isWebEditingHostTarget } from "@interactive-os/json-document-web";
import type {
ContentEditableBinding,
ContentEditableBindingOptions,
Expand Down Expand Up @@ -76,6 +77,11 @@ export function createContentEditableBinding({
trailingComposition = false;
};

const finishTrailing = (): void => {
clearTrailing();
renderLatest(undefined, true);
};

const clearActiveLease = (): void => {
const fallback = activeLease?.nativeFallback;
if (fallback !== null && fallback !== undefined) clearTimeout(fallback);
Expand Down Expand Up @@ -169,14 +175,14 @@ export function createContentEditableBinding({
if (event.type === "beforeinput") {
if (trailingComposition) {
if (isCompositionInput(event)) return NO_CHANGE;
clearTrailing();
finishTrailing();
}
if (activeLease !== null) return NO_CHANGE;
return begin("native", event);
}

if (event.type === "compositionstart") {
if (trailingComposition) clearTrailing();
if (trailingComposition) finishTrailing();
return begin("composing", event);
}

Expand All @@ -185,16 +191,13 @@ export function createContentEditableBinding({
activeLease.phase = "composing";
const result = commitObservation();
trailingComposition = true;
trailingTimer = setTimeout(() => {
trailingTimer = null;
trailingComposition = false;
}, 0);
trailingTimer = setTimeout(finishTrailing, 0);
return result;
}

if (event.type === "input") {
if (trailingComposition) {
clearTrailing();
finishTrailing();
return NO_CHANGE;
}
if (activeLease?.phase === "composing") return NO_CHANGE;
Expand All @@ -210,7 +213,7 @@ export function createContentEditableBinding({
};

const boundHandle = (event: Event): void => {
if (!eventBelongsToRoot(event, root)) return;
if (!isWebEditingHostTarget(root, event.target)) return;
handleInternal(event);
};

Expand Down Expand Up @@ -261,50 +264,6 @@ export function createContentEditableBinding({
});
}

function eventBelongsToRoot(event: Event, root: HTMLElement): boolean {
const target = event.target;
const view = root.ownerDocument.defaultView;
if (view === null || !(target instanceof view.Node)) return false;
if (target === root) return true;
if (!root.contains(target)) return false;

let element = target instanceof view.Element
? target
: target.parentElement;
while (element !== null && element !== root) {
const tag = element.tagName.toLowerCase();
if (
tag === "input"
|| tag === "textarea"
|| tag === "select"
|| tag === "option"
) {
return false;
}
const editable = element.getAttribute("contenteditable");
const editableProperty = "contentEditable" in element
&& typeof element.contentEditable === "string"
? element.contentEditable.toLowerCase()
: "";
if (
(editable !== null || editableProperty !== "")
&& (
editable === ""
|| editable?.toLowerCase() === "true"
|| editable?.toLowerCase() === "plaintext-only"
|| editable?.toLowerCase() === "false"
|| editableProperty === "true"
|| editableProperty === "plaintext-only"
|| editableProperty === "false"
)
) {
return false;
}
element = element.parentElement;
}
return true;
}

function isCompositionInput(event: Event): boolean {
const inputType = "inputType" in event
&& typeof event.inputType === "string"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createJSONDocument } from "@interactive-os/json-document";
import { afterEach, expect, it, vi } from "vitest";
import { createContentEditableBinding } from "../src/index.js";

afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
});

it.each(["timer", "trailing-input"])(
"publishes the latest model when composition tail ends via %s",
(ending) => {
vi.useFakeTimers();
const json = createJSONDocument({ title: "ab" });
const root = document.createElement("div");
root.contentEditable = "true";
document.body.append(root);
const binding = createContentEditableBinding({
document: json,
pointer: "/title",
root,
});
const unbind = binding.bind();
try {
root.dispatchEvent(
new CompositionEvent("compositionstart", { bubbles: true }),
);
root.textContent = "ab한";
root.dispatchEvent(
new CompositionEvent("compositionend", { bubbles: true, data: "한" }),
);
expect(json.at("/title")).toMatchObject({ ok: true, value: "ab한" });
json.commit([{ op: "replace", path: "/title", value: "latest" }]);
if (ending === "timer") vi.runAllTimers();
else
root.dispatchEvent(
new InputEvent("input", {
bubbles: true,
inputType: "insertFromComposition",
data: "한",
}),
);
expect(root.textContent).toBe("latest");
root.dispatchEvent(
new InputEvent("beforeinput", {
bubbles: true,
cancelable: true,
inputType: "insertText",
data: "!",
}),
);
root.textContent += "!";
root.dispatchEvent(
new InputEvent("input", {
bubbles: true,
inputType: "insertText",
data: "!",
}),
);
expect(json.at("/title")).toMatchObject({ ok: true, value: "latest!" });
} finally {
unbind();
}
},
);
2 changes: 1 addition & 1 deletion packages/json-document-contenteditable/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [{ "path": "../json-document" }],
"references": [{ "path": "../json-document" }, { "path": "../json-document-web" }],
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
11 changes: 11 additions & 0 deletions packages/json-document-rich-text-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ the final native DOM diff into the canonical document, the surface resumes from
the committed snapshot. This prevents React renders from terminating Korean
jamo composition or duplicating the final `insertText` event.

The render lease belongs to each mounted surface, not to the shared editor.
Other surfaces continue observing the model while one surface composes. Release
catches up every intervening model change and restores the affected block's DOM
and selection even after cancellation or rejected input. Ordinary typing keeps
the incremental render path.

Changes to `onAction` or `createId` use the latest callbacks without restarting
the binding. An editor change, `as` root replacement, or unmount ends the old
binding and its active composition. Hosts need not memoize callbacks to protect
native input.

The official editable surface enforces `white-space: pre-wrap` so consecutive
U+0020 spaces remain visible and caret geometry stays aligned with canonical
UTF-16 offsets. Other host-provided inline styles are preserved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
createRichTextEditor,
} from "@interactive-os/json-document-rich-text";
import { benchmarkConfig, measure, reportScaling } from "../../../benchmarks/measure.mjs";
import { richTextRenderStore } from "../dist/render-store.js";
import { createRichTextRenderStore } from "../dist/render-store.js";

const config = benchmarkConfig("PERF_RICH_TEXT_REACT_BLOCKS", [1_000, 10_000]);
const rows = [];
Expand All @@ -19,7 +19,7 @@ for (const size of config.sizes) {
document: createJSONDocument(createRichTextBlockFixture(size, { idPrefix: `p${size}` })),
selection: collapsed(`p${size}-text-${middle}`, 1),
});
richTextRenderStore(editor).subscribePlaceholder(() => {});
createRichTextRenderStore(editor).subscribePlaceholder(() => {});
return () => editor.dispatch({ type: "text.insert", text: "y" }).ok;
});
rows.push({ size, ...result });
Expand Down
Loading