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
7 changes: 7 additions & 0 deletions docs/api-reference/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ const sheetClipboardCodec: WebClipboardCodec<SheetClipboard>
```ts
textInputFromControl(event: WebTextControlEvent): WebTextInput
```
## `textSelectionFromControl`

```ts
textSelectionFromControl(event: WebTextControlEvent): SelectionRange<number>
```
## `treeClipboardCodec`

```ts
Expand Down Expand Up @@ -798,6 +803,8 @@ webSVGViewportFromElement(svg: WebSVGElement): WebSVGViewport
interface WebTextControl {
readonly value: string;
readonly selectionStart: number | null;
readonly selectionEnd?: number | null;
readonly selectionDirection?: "forward" | "backward" | "none" | null;
}
```
## `WebTextControlEvent`
Expand Down
10 changes: 9 additions & 1 deletion docs/public/react-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,15 @@ import { DocumentTextControl } from "@interactive-os/json-document-react";

`useDocumentTextControl(options)`은 같은 계약을 `ref`와 `props`로 반환합니다.
Host가 textarea element를 직접 구성해야 할 때 사용합니다. 두 API 모두
Web의 `textInputFromControl`과 Affordance의 caret/click 계약을 합성합니다.
Web의 `textInputFromControl`, `textSelectionFromControl`과 Affordance의
caret/click 계약을 합성합니다. `onCaretRange`의 `from`, `to`는 방향을 보존하는
anchor, focus입니다. 역방향 선택에서는 `from > to`일 수 있습니다.

네이티브 focus가 전달한 `offset`과 같으면 이미 선택된 범위를 유지하고, 다른
`offset`이면 커서를 그 위치로 복원합니다. offset 하나로 과거의 네이티브 범위를
재구성하지는 않습니다. `DocumentTextControl`이 있는 textarea의 방향키·Shift 선택은
네이티브 입력이 담당하므로 외부 구조 탐색 binding에 `keyboard.text`를 중복 연결할
필요가 없습니다.
Host는 `rows`, class, 제품 문구와 실제 Document Intent dispatch를 소유합니다.

Document selection의 primary point만 필요하면 Editing의
Expand Down
14 changes: 14 additions & 0 deletions packages/json-document-editing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,17 @@ Existing public API references and live Usage remain in
[Editing](https://developer-1px.github.io/json-document/docs/api/editing),
[Document](https://developer-1px.github.io/json-document/demo), and
[Sheet](https://developer-1px.github.io/json-document/demo/sheet).

## Document offset selection

For `selection.set`, replace/collapse and extend compare the complete
`DocumentPoint` (`blockId` and `offset`). Moving within one block updates the
caret; extension preserves the primary anchor and changes its focus, including
backward ranges. Offsets are clamped to the block's text bounds.

`mode: "toggle"` continues to address the whole block independently of its text
offsets. Copy still projects whole selected blocks. Selection-only movement
preserves document values and existing Undo/Redo records; Undo after an edit
restores the recorded offset range. These contracts are exercised by
[Document editor tests](tests/document-editor.test.ts) and the existing
[Document Usage](https://developer-1px.github.io/json-document/demo).
4 changes: 3 additions & 1 deletion packages/json-document-editing/src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ export function createDocumentEditor(source: EditingDocumentSource<BlockDocument
session.snapshot.selection,
point,
intent.mode ?? "replace",
(left, right) => left.blockId === right.blockId,
// Toggle addresses whole blocks; caret/range endpoints also include offset.
(left, right) => left.blockId === right.blockId
&& (intent.mode === "toggle" || left.offset === right.offset),
);
return success(session.select(asDocumentSelection(selection)));
}
Expand Down
73 changes: 72 additions & 1 deletion packages/json-document-editing/tests/document-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,78 @@
import { describe, expect, test } from "vitest";
import { createDocumentEditor, documentSelectionFocus } from "../src/index.js";
import { createDocumentEditor, documentSelectionFocus, type DocumentSelection } from "../src/index.js";

describe("document editing vertical slice", () => {
test("moves, extends, and collapses offsets inside the same block without editing its contents", () => {
const initial = { blocks: [{ id: "a", text: "Alpha" }] };
const editor = createDocumentEditor(initial);
const published: DocumentSelection[] = [];
const release = editor.subscribe((snapshot) => published.push(snapshot.selection));
const selection = (anchor: number, focus = anchor): DocumentSelection => ({
kind: "range", primaryIndex: 0,
ranges: [{ anchor: { blockId: "a", offset: anchor }, focus: { blockId: "a", offset: focus } }],
});
const steps = [
{ offset: 2, mode: "replace", expected: selection(2) },
{ offset: 4, mode: "extend", expected: selection(2, 4) },
{ offset: 1, mode: "extend", expected: selection(2, 1) },
{ offset: 3, mode: "replace", expected: selection(3) },
{ offset: 99, mode: "extend", expected: selection(3, 5) },
{ offset: -1, mode: "extend", expected: selection(3, 0) },
{ offset: 99, mode: "replace", expected: selection(5) },
{ offset: -1, mode: "replace", expected: selection(0) },
] as const;
try {
for (const { offset, mode, expected } of steps) {
expect(editor.dispatch({ type: "selection.set", blockId: "a", offset, mode }))
.toMatchObject({ ok: true, snapshot: { value: initial, selection: expected, canUndo: false, canRedo: false } });
expect(editor.snapshot.selection).toEqual(expected);
expect(editor.selectedBlockIds).toEqual(["a"]);
expect(editor.copy()?.blocks).toEqual(initial.blocks);
expect(published.at(-1)).toEqual(expected);
}
expect(published).toEqual(steps.map((step) => step.expected));
} finally { release(); }
});

test("keeps block toggle independent of the selected offsets", () => {
const editor = createDocumentEditor({ blocks: [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }] });
editor.dispatch({ type: "selection.set", blockId: "a", offset: 2 });
editor.dispatch({ type: "selection.set", blockId: "a", offset: 4, mode: "extend" });
const original = editor.snapshot.selection;
editor.dispatch({ type: "selection.set", blockId: "b", offset: 1, mode: "toggle" });
expect(editor.selectedBlockIds).toEqual(["a", "b"]);
editor.dispatch({ type: "selection.set", blockId: "b", offset: 3, mode: "toggle" });
expect(editor.snapshot.selection).toEqual(original);
expect(editor.selectedBlockIds).toEqual(["a"]);
editor.dispatch({ type: "selection.set", blockId: "a", offset: 0, mode: "toggle" });
expect(editor.selectedBlockIds).toEqual([]);
expect(editor.snapshot.selection).toEqual({ kind: "range", ranges: [], primaryIndex: null });
});

test("restores offset ranges on undo and preserves redo through caret-only movement", () => {
const initial = { blocks: [{ id: "a", text: "Alpha" }] };
const editor = createDocumentEditor(initial);
editor.dispatch({ type: "selection.set", blockId: "a", offset: 4 });
editor.dispatch({ type: "selection.set", blockId: "a", offset: 1, mode: "extend" });
const before = {
value: initial,
selection: { kind: "range", primaryIndex: 0, ranges: [{
anchor: { blockId: "a", offset: 4 }, focus: { blockId: "a", offset: 1 },
}] },
};
expect(editor.snapshot).toMatchObject(before);
expect(editor.dispatch({ type: "text.replace", blockId: "a", text: "Alps", offset: 3 }).ok).toBe(true);
const after = editor.snapshot;
expect(editor.undo()).toMatchObject({ ok: true, snapshot: { ...before, canUndo: false, canRedo: true } });
expect(editor.dispatch({ type: "selection.set", blockId: "a", offset: 2 }))
.toMatchObject({ ok: true, snapshot: { value: initial, canUndo: false, canRedo: true } });
expect(documentSelectionFocus(editor.snapshot.selection)?.offset).toBe(2);
expect(editor.redo()).toMatchObject({ ok: true, snapshot: {
value: after.value, selection: after.selection, canUndo: true, canRedo: false,
} });
expect(editor.undo()).toMatchObject({ ok: true, snapshot: { ...before, canUndo: false, canRedo: true } });
});

test("preserves group placement for every selection and direction up to six blocks", () => {
for (let size = 1; size <= 6; size += 1) {
for (let mask = 1; mask < 2 ** size; mask += 1) {
Expand Down
7 changes: 7 additions & 0 deletions packages/json-document-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ textarea.
`useDocumentTextControl` composes cursor restoration, Web text input, and
caret/click affordances into reusable textarea props. `DocumentTextControl`
renders that same lifecycle while the host keeps layout and Document Intent.
`onCaretRange` receives directional anchor/focus offsets projected by Web's
`textSelectionFromControl`, including backward selections. When native focus
already equals the supplied `offset`, the native range is preserved. A different
`offset` restores a collapsed caret through `restoreTextCursor`; an offset alone
does not encode a historical native range. Native text controls keep their own
arrow/Shift selection handling; the outer structural keyboard binding need not
also configure `keyboard.text` for those controls.

`useGridEditing` is the grid-specific React entry point. It accepts canonical
`GridPoint` values through `selectedPoints`, `focusPoint`, `onSelect`, and
Expand Down
26 changes: 18 additions & 8 deletions packages/json-document-react/src/use-document-text-control.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { createElement, useRef, type TextareaHTMLAttributes } from "react";
import { createElement, useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react";
import {
applyAffordance,
caretAffordance,
caretCursor,
clickCountAffordance,
} from "@interactive-os/json-document-affordance";
import { textInputFromControl, type WebTextInput } from "@interactive-os/json-document-web";
import { useRestoreTextCursor } from "./use-editing.js";
import { textInputFromControl, textSelectionFromControl, type WebTextInput } from "@interactive-os/json-document-web";
import { restoreTextCursor } from "./use-editing.js";

export interface UseDocumentTextControlOptions {
readonly text: string;
Expand All @@ -32,21 +32,30 @@ export interface DocumentTextControlProps extends UseDocumentTextControlOptions,
/** Composes the official Web input and caret affordances into a React textarea lifecycle. */
export function useDocumentTextControl(options: UseDocumentTextControlOptions): DocumentTextControlBinding {
const ref = useRef<HTMLTextAreaElement>(null);
useRestoreTextCursor(ref, options.offset);
useLayoutEffect(() => {
const control = ref.current;
if (control === null || options.offset === null) return;
// Native selection already owns the range when its focus matches the model.
// Echoing that focus as a collapsed cursor would emit another select event.
if (textSelectionFromControl({ currentTarget: control }).focus !== options.offset) {
restoreTextCursor(control, options.offset);
}
}, [options.offset]);

return {
ref,
props: {
value: options.text,
onFocus(event) {
const offset = textInputFromControl(event).offset;
options.onCaretRange(offset, offset, "replace");
const { anchor, focus } = textSelectionFromControl(event);
options.onCaretRange(anchor, focus, "replace");
},
onClick(event) {
applyAffordance(caretAffordance({ type: "pointer" }), {
hand(hand) {
if (hand.type === "caret") {
options.onCaretRange(event.currentTarget.selectionStart, event.currentTarget.selectionEnd, hand.operation);
const { anchor, focus } = textSelectionFromControl(event);
options.onCaretRange(anchor, focus, hand.operation);
}
},
});
Expand All @@ -60,7 +69,8 @@ export function useDocumentTextControl(options: UseDocumentTextControlOptions):
applyAffordance(caretAffordance({ type: "pointer", dragging: true }), {
hand(hand) {
if (hand.type === "caret") {
options.onCaretRange(event.currentTarget.selectionStart, event.currentTarget.selectionEnd, hand.operation);
const { anchor, focus } = textSelectionFromControl(event);
options.onCaretRange(anchor, focus, hand.operation);
}
},
});
Expand Down
38 changes: 38 additions & 0 deletions packages/json-document-react/tests/react-connector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,44 @@ describe("React Connector", () => {
expect(inputs).toEqual([{ text: "Alps", offset: 4 }]);
});

test("keeps native directional ranges when Document publishes their focus offset", () => {
const editor = createDocumentEditor({ blocks: [{ id: "a", text: "Alpha" }] });
function View() {
const snapshot = useEditingSnapshot(editor);
return <DocumentTextControl
aria-label="Bound Document text"
text="Alpha"
offset={snapshot.selection.ranges[0]?.focus.offset ?? null}
onCaretRange={(anchor, focus, mode) => {
editor.dispatch({ type: "selection.set", blockId: "a", offset: anchor });
if (mode === "extend" || anchor !== focus) {
editor.dispatch({ type: "selection.set", blockId: "a", offset: focus, mode: "extend" });
}
}}
onTextInput={() => {}}
/>;
}
render(<View />);
const control = screen.getByRole("textbox", { name: "Bound Document text" }) as HTMLTextAreaElement;
act(() => { control.focus(); });
for (const [start, end, direction, anchor, focus] of [
[2, 4, "forward", 2, 4],
[1, 2, "backward", 2, 1],
[3, 3, "none", 3, 3],
] as const) {
control.setSelectionRange(start, end, direction);
fireEvent.select(control);
expect(editor.snapshot.selection.ranges).toEqual([{
anchor: { blockId: "a", offset: anchor }, focus: { blockId: "a", offset: focus },
}]);
expect([control.selectionStart, control.selectionEnd]).toEqual([start, end]);
if (direction !== "none") expect(control.selectionDirection).toBe(direction);
}
expect(editor.snapshot).toMatchObject({ value: { blocks: [{ id: "a", text: "Alpha" }] }, canUndo: false, canRedo: false });
act(() => { editor.dispatch({ type: "selection.set", blockId: "a", offset: 0 }); });
expect([control.selectionStart, control.selectionEnd]).toEqual([0, 0]);
});

test("exposes the shared document through the official Connector entry point", () => {
const document = createJSONDocument({ title: "Draft" });
function View() {
Expand Down
22 changes: 22 additions & 0 deletions packages/json-document-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,25 @@ Document editor to check each write failure, schema-rejected removal, successful
capture-before-removal, and selection-restoring Undo. Existing unsupported-format
cases retain their event ownership behavior. These tests exercise the Web event
port; they do not certify browser-specific clipboard permissions or transport.

## Native text selection

`textSelectionFromControl({ currentTarget })` projects an input or textarea's
`selectionStart`, `selectionEnd`, and `selectionDirection` into the existing
`SelectionRange<number>` anchor/focus contract. A backward native selection has
its anchor at the end and focus at the start. Bounds are clamped to the text.
A missing `selectionEnd` produces a collapsed selection; a missing direction
uses start as anchor and end as focus. The existing
`textInputFromControl` text/offset result is unchanged.

```ts
import { textSelectionFromControl } from "@interactive-os/json-document-web";

const range = textSelectionFromControl({ currentTarget: textarea });
// range.anchor and range.focus preserve the native selection direction.
```

`DocumentTextControl` consumes this public projection in the live
[Document Usage](https://developer-1px.github.io/json-document/demo); its source
view links the React binding to this package's `input.ts` implementation and
[API reference](https://developer-1px.github.io/json-document/docs/api/web).
2 changes: 1 addition & 1 deletion packages/json-document-web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export {
treeClipboardCodec,
} from "./clipboard.js";
export { selectionOperationFromModifiers } from "./modifiers.js";
export { isWebEditableTarget, isWebEditingHostTarget, textInputFromControl } from "./input.js";
export { isWebEditableTarget, isWebEditingHostTarget, textInputFromControl, textSelectionFromControl } from "./input.js";
export { pressInteractionFromWeb } from "./press.js";
export { focusWebItem, webFocusItemProps } from "./focus-item.js";
export { findWebGridCell, webGridCellAddressProps } from "./grid-cell.js";
Expand Down
13 changes: 13 additions & 0 deletions packages/json-document-web/src/input.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { SelectionRange } from "@interactive-os/json-document-selection";

export interface WebTextControl {
readonly value: string;
readonly selectionStart: number | null;
readonly selectionEnd?: number | null;
readonly selectionDirection?: "forward" | "backward" | "none" | null;
}

export interface WebTextControlEvent {
Expand All @@ -18,6 +22,15 @@ export function textInputFromControl(event: WebTextControlEvent): WebTextInput {
return { text, offset: Math.min(text.length, Math.max(0, offset)) };
}

/** Projects a native text control's directional selection into anchor/focus offsets. */
export function textSelectionFromControl(event: WebTextControlEvent): SelectionRange<number> {
const { text, offset: start } = textInputFromControl(event);
const end = Math.min(text.length, Math.max(start, event.currentTarget.selectionEnd ?? start));
return event.currentTarget.selectionDirection === "backward"
? { anchor: end, focus: start }
: { anchor: start, focus: end };
}

export function isWebEditableTarget(target: object | null): boolean {
if (!(target instanceof Element)) return false;
return target instanceof HTMLInputElement
Expand Down
17 changes: 17 additions & 0 deletions packages/json-document-web/tests/web-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
selectionOperationFromModifiers,
sheetClipboardCodec,
textInputFromControl,
textSelectionFromControl,
webFocusItemProps,
webGridCellAddressProps,
webKanbanCardProps,
Expand All @@ -54,6 +55,22 @@ import {
type WebClipboardEvent,
} from "../src/index.js";

describe("native text selection projection", () => {
test.each([
{ selectionStart: 2, selectionEnd: 4, selectionDirection: "forward", expected: { anchor: 2, focus: 4 } },
{ selectionStart: 1, selectionEnd: 4, selectionDirection: "backward", expected: { anchor: 4, focus: 1 } },
{ selectionStart: 2, selectionEnd: 2, selectionDirection: "none", expected: { anchor: 2, focus: 2 } },
{ selectionStart: -1, selectionEnd: 99, selectionDirection: "backward", expected: { anchor: 5, focus: 0 } },
{ selectionStart: null, selectionEnd: null, selectionDirection: null, expected: { anchor: 5, focus: 5 } },
] as const)("preserves anchor/focus for $selectionDirection $selectionStart:$selectionEnd", ({ expected, ...selection }) => {
expect(textSelectionFromControl({ currentTarget: { value: "Alpha", ...selection } })).toEqual(expected);
});

test("accepts an existing cursor-only control as a collapsed selection", () => {
expect(textSelectionFromControl({ currentTarget: { value: "Alpha", selectionStart: 2 } })).toEqual({ anchor: 2, focus: 2 });
});
});

describe("Web file intake translation", () => {
const files = [
{ name: "brief.png", size: 24, type: "image/png" },
Expand Down
13 changes: 0 additions & 13 deletions site/src/routes/document-demo/DocumentDemoRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,6 @@ export function DocumentDemoRoute() {
onRedo: () => {
run(() => editor.redo(), "Redone");
},
text: {
offset: () => documentSelectionFocus(editor.snapshot.selection)?.offset ?? 0,
length: () => {
const blockId = documentSelectionFocus(editor.snapshot.selection)?.blockId;
const block = (editor.snapshot.value as BlockDocument).blocks.find((item) => item.id === blockId);
return block?.text.length ?? 0;
},
onOffset: (offset, mode) => {
const blockId = documentSelectionFocus(editor.snapshot.selection)?.blockId;
if (!blockId) return;
run(() => dispatchIntent({ type: "selection.set", blockId, mode, offset }), "Selection changed");
},
},
},
});
const snapshot = editing.snapshot;
Expand Down
5 changes: 5 additions & 0 deletions site/src/shared/demo-workbench/demo-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ const registeredUsageSources = new Map<string, string>([
["packages/json-document-zod/src/index.ts", zodSource],
]);
const registeredPublicUsages = [
{
packageName: "@interactive-os/json-document-web",
symbol: "textSelectionFromControl",
sourcePath: "packages/json-document-web/src/input.ts",
},
{
packageName: "@interactive-os/json-document-web",
symbol: "isWebEditingHostTarget",
Expand Down
Loading