From 463c1273c202f7c10edf746a672b3c3a8fb37ba0 Mon Sep 17 00:00:00 2001 From: daesunp <138815173+daesunp@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:06:36 +0000 Subject: [PATCH 01/10] extract reusable functions from plainTextView --- .../react/src/test/text/plainUtils.test.ts | 105 +++++++++++- .../framework/react/src/text/plain/index.ts | 8 +- .../react/src/text/plain/plainTextView.tsx | 81 ++------- .../react/src/text/plain/plainUtils.ts | 158 +++++++++++++++++- 4 files changed, 281 insertions(+), 71 deletions(-) diff --git a/packages/framework/react/src/test/text/plainUtils.test.ts b/packages/framework/react/src/test/text/plainUtils.test.ts index 21a9ba1782d4..5057e267c642 100644 --- a/packages/framework/react/src/test/text/plainUtils.test.ts +++ b/packages/framework/react/src/test/text/plainUtils.test.ts @@ -5,11 +5,114 @@ import { strict as assert } from "node:assert"; +import { TextAsTree } from "@fluidframework/tree/internal"; +import { + independentView, + TreeViewConfiguration, + type TreeViewAlpha, +} from "@fluidframework/tree/alpha"; + +import { createUndoRedo } from "../../undoRedo.js"; // Allow import of file being tested // eslint-disable-next-line import-x/no-internal-modules -import { computeSync } from "../../text/plain/plainUtils.js"; +import { applyTextEdit, applyTextOps, computeSync } from "../../text/plain/plainUtils.js"; describe("plainUtils", () => { + describe("applyTextOps", () => { + it("inserts text, leaving the selection start before the edit and extending the end past it", () => { + // "hello" with selection "el" (1-3); insert "XX" at index 2, inside the selection. + // start (before the insert) is unchanged; end (after it) shifts by the inserted length. + const result = applyTextOps("hello", { start: 1, end: 3 }, [ + { type: "retain", count: 2 }, + { type: "insert", text: "XX" }, + { type: "retain", count: 3 }, + ]); + assert.deepEqual(result, { value: "heXXllo", selection: { start: 1, end: 5 } }); + }); + + it("removes text, clamping a selection start inside the removal and pulling back the end after it", () => { + // "hello" with selection "llo" (2-5); remove "ell" (1-4). + // start sits inside the removal and clamps to its start; end is past it and pulls back. + const result = applyTextOps("hello", { start: 2, end: 5 }, [ + { type: "retain", count: 1 }, + { type: "remove", count: 3 }, + { type: "retain", count: 1 }, + ]); + assert.deepEqual(result, { value: "ho", selection: { start: 1, end: 2 } }); + }); + + it("treats op counts as code points, not UTF-16 units, for astral characters", () => { + // "😀" is one code point but two UTF-16 units. Retain it, then insert after. + const value = "😀x"; + // Cursor was at end (3 UTF-16 units); the insert is before it, so it shifts by 1. + const result = applyTextOps(value, { start: value.length, end: value.length }, [ + { type: "retain", count: 1 }, + { type: "insert", text: "Y" }, + { type: "retain", count: 1 }, + ]); + assert.deepEqual(result, { value: "😀Yx", selection: { start: 4, end: 4 } }); + }); + + it("appends the tail of the old value not covered by trailing ops", () => { + const result = applyTextOps("hello", { start: 0, end: 0 }, [ + { type: "retain", count: 2 }, + ]); + assert.deepEqual(result, { value: "hello", selection: { start: 0, end: 0 } }); + }); + + it("clamps a stale selection that lands outside the new value", () => { + // Selection points past the end of oldValue; after a shrinking edit it must not exceed value.length. + const result = applyTextOps("hello", { start: 10, end: 10 }, [ + { type: "remove", count: 3 }, + { type: "retain", count: 2 }, + ]); + assert.deepEqual(result, { value: "lo", selection: { start: 2, end: 2 } }); + }); + }); + + describe("applyTextEdit", () => { + function createTextView(initial: string): TreeViewAlpha { + const view = independentView(new TreeViewConfiguration({ schema: TextAsTree.Tree })); + view.initialize(TextAsTree.Tree.fromString(initial)); + return view; + } + + it("applies the edit directly to an unhydrated (non-branch) node", () => { + const root = TextAsTree.Tree.fromString("hello"); + applyTextEdit(root, "hello world"); + assert.equal(root.fullString(), "hello world"); + }); + + it("wraps the edit in a transaction tagged with the given label when on a branch", () => { + const view = createTextView("hello"); + const label = Symbol("editor"); + const manager = createUndoRedo(view); + + applyTextEdit(view.root, "hello world", label); + assert.equal(view.root.fullString(), "hello world"); + + // The edit was a single transaction tagged with `label`, so a label-scoped undo + // reverts the whole edit in one step. + assert(manager.canUndo(label)); + manager.undo(label); + assert.equal(view.root.fullString(), "hello"); + manager.dispose(); + }); + + it("defaults the transaction label to the root node when none is given", () => { + const view = createTextView("hello"); + const manager = createUndoRedo(view); + + applyTextEdit(view.root, "hello world"); + + // With no explicit label, the edit is tagged with `root` itself. + assert(manager.canUndo(view.root)); + manager.undo(view.root); + assert.equal(view.root.fullString(), "hello"); + manager.dispose(); + }); + }); + describe("computeSync", () => { /** * Calls computeSync, applies the returned ops to a copy of `existing`, diff --git a/packages/framework/react/src/text/plain/index.ts b/packages/framework/react/src/text/plain/index.ts index 91cf8ed288c0..271d43a66ce2 100644 --- a/packages/framework/react/src/text/plain/index.ts +++ b/packages/framework/react/src/text/plain/index.ts @@ -7,4 +7,10 @@ export { MainView as PlainTextMainView, type MainViewProps as PlainTextMainViewProps, } from "./plainTextView.js"; -export { syncTextToTree } from "./plainUtils.js"; +export { + applyTextEdit, + applyTextOps, + type ApplyTextOpsResult, + syncTextToTree, + type TextSelection, +} from "./plainUtils.js"; diff --git a/packages/framework/react/src/text/plain/plainTextView.tsx b/packages/framework/react/src/text/plain/plainTextView.tsx index 3d52fdb29364..68d926cae7e6 100644 --- a/packages/framework/react/src/text/plain/plainTextView.tsx +++ b/packages/framework/react/src/text/plain/plainTextView.tsx @@ -3,17 +3,13 @@ * Licensed under the MIT License. */ -import { - type TextAsTree, - TreeAlpha, - utf16LengthForCodePoints, -} from "@fluidframework/tree/internal"; +import type { TextAsTree } from "@fluidframework/tree/internal"; import { type ChangeEvent, type FC, useCallback, useEffect, useReducer, useRef } from "react"; import { unwrapPropTreeNode, type PropTreeNode } from "../../propNode.js"; import type { TextEditorProps } from "../textEditorProps.js"; -import { syncTextToTree } from "./plainUtils.js"; +import { applyTextEdit, applyTextOps, type TextSelection } from "./plainUtils.js"; /** * Props for the MainView component. @@ -76,70 +72,27 @@ const PlainTextEditorView: FC = ({ root, undoRedo, editLabel try { const textarea = textareaRef.current; let newValue: string; - let newCursorStart: number | undefined; - let newCursorEnd: number | undefined; + let newSelection: TextSelection | undefined; if (ops === undefined) { // Delta unavailable — fall back to full re-read. newValue = root.fullString(); } else { // Apply ops incrementally to avoid a full O(N) re-read. - const selectionStart = textarea.selectionStart; - const selectionEnd = textarea.selectionEnd; - - // readPos is a UTF-16 code-unit index into oldValue. - // op.count is in Unicode code points; we convert via utf16LengthForCodePoints. - let readPos = 0; - const oldValue = textarea.value; - newValue = ""; - newCursorStart = selectionStart; - newCursorEnd = selectionEnd; - - for (const op of ops) { - if (op.type === "retain") { - // Convert atom count to UTF-16 units by scanning the actual characters. - const utf16Count = utf16LengthForCodePoints(oldValue, readPos, op.count); - newValue += oldValue.slice(readPos, readPos + utf16Count); - readPos += utf16Count; - } else if (op.type === "insert") { - // op.text is a JS string; use its UTF-16 length for cursor adjustment. - if (readPos <= selectionStart) { - newCursorStart += op.text.length; - } - if (readPos <= selectionEnd) { - newCursorEnd += op.text.length; - } - newValue += op.text; - } else { - // remove - // Convert atom count to UTF-16 units before adjusting cursors. - const utf16Count = utf16LengthForCodePoints(oldValue, readPos, op.count); - const removeEnd = readPos + utf16Count; - if (removeEnd <= selectionStart) { - newCursorStart -= utf16Count; - } else if (readPos < selectionStart) { - newCursorStart -= selectionStart - readPos; - } - if (removeEnd <= selectionEnd) { - newCursorEnd -= utf16Count; - } else if (readPos < selectionEnd) { - newCursorEnd -= selectionEnd - readPos; - } - readPos += utf16Count; - } - } - - // Append any tail not covered by ops (e.g. trailing retained content). - newValue += oldValue.slice(readPos); + const result = applyTextOps( + textarea.value, + { start: textarea.selectionStart, end: textarea.selectionEnd }, + ops, + ); + newValue = result.value; + newSelection = result.selection; } textarea.value = newValue; // Keep the DOM's child text node in sync with `.value` so queries like // `element.textContent` (used in tests / by external observers) reflect current // content rather than the initial value baked in from `defaultValue`. textarea.textContent = newValue; - if (newCursorStart !== undefined && newCursorEnd !== undefined) { - const clampedStart = Math.min(newCursorStart, newValue.length); - const clampedEnd = Math.min(newCursorEnd, newValue.length); - textarea.setSelectionRange(clampedStart, clampedEnd); + if (newSelection !== undefined) { + textarea.setSelectionRange(newSelection.start, newSelection.end); } } finally { isUpdatingRef.current = false; @@ -158,15 +111,7 @@ const PlainTextEditorView: FC = ({ root, undoRedo, editLabel isUpdatingRef.current = true; try { - const newText = event.target.value; - const context = TreeAlpha.context(root); - if (context.isBranch()) { - context.runTransaction(() => syncTextToTree(root, newText), { - label: effectiveLabel, - }); - } else { - syncTextToTree(root, newText); - } + applyTextEdit(root, event.target.value, effectiveLabel); } finally { isUpdatingRef.current = false; } diff --git a/packages/framework/react/src/text/plain/plainUtils.ts b/packages/framework/react/src/text/plain/plainUtils.ts index 4b3aab48ca1e..3eb180ea4761 100644 --- a/packages/framework/react/src/text/plain/plainUtils.ts +++ b/packages/framework/react/src/text/plain/plainUtils.ts @@ -3,7 +3,163 @@ * Licensed under the MIT License. */ -import type { TextAsTree } from "@fluidframework/tree/internal"; +import { + type TextAsTree, + TreeAlpha, + utf16LengthForCodePoints, +} from "@fluidframework/tree/internal"; + +/** + * A text selection or cursor range expressed as UTF-16 code-unit offsets. + * @remarks + * These match the `selectionStart` / `selectionEnd` properties of an HTML `` / `