diff --git a/packages/core/README.md b/packages/core/README.md index 5d89c3d..13cef4b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -13,9 +13,25 @@ pnpm add @pen/core ## What It Provides - `createEditor(...)` to create editor instances +- `createHeadlessEditor(...)` for server-side, worker, and test workflows that need editor semantics without a renderer - document state, selection, normalization, and mutation orchestration - the canonical `editor.apply(...)` document mutation boundary +## Headless Usage + +```ts +import { createHeadlessEditor } from "@pen/core"; +import { yjsAdapter, wrapYjsDocument } from "@pen/crdt-yjs"; + +const adapter = yjsAdapter(); +const editor = createHeadlessEditor({ + crdt: adapter, + document: wrapYjsDocument(adapter, ydoc), +}); +``` + +Use this shape for migrations, AI workers, export workers, and tests that should run through Pen's mutation pipeline without mounting a UI. + ## Typical Pairing Most apps use `@pen/core` with: diff --git a/packages/core/src/__tests__/databaseOps.test.ts b/packages/core/src/__tests__/databaseOps.part1.test.ts similarity index 58% rename from packages/core/src/__tests__/databaseOps.test.ts rename to packages/core/src/__tests__/databaseOps.part1.test.ts index 59148f8..d4bcbe1 100644 --- a/packages/core/src/__tests__/databaseOps.test.ts +++ b/packages/core/src/__tests__/databaseOps.part1.test.ts @@ -32,6 +32,7 @@ function databaseEditor() { return editor; } + describe("database core operations", () => { it("insert-block with database type seeds shared grid structures", () => { const editor = databaseEditor(); @@ -402,294 +403,4 @@ describe("database core operations", () => { editor.destroy(); }); - it("normalizes invalid database view references on write", () => { - const editor = databaseEditor(); - - editor.apply([ - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-a", - values: { - name: "Alpha", - tags: "todo", - status: "true", - }, - }, - { - type: "database-update-view", - blockId: "d1", - patch: { - visibleColumnIds: ["name", "missing", "name"], - columnOrder: ["missing", "tags", "name", "tags"], - sort: [ - { columnId: "missing", direction: "asc" }, - { columnId: "tags", direction: "asc" }, - { columnId: "tags", direction: "desc" }, - ], - filter: { - operator: "and", - conditions: [ - { columnId: "missing", operator: "is", value: "x" }, - { columnId: "tags", operator: "is", value: "todo" }, - ], - }, - groupBy: "missing", - rowPinning: { - top: ["missing-row", "row-a", "row-a"], - bottom: ["row-a", "missing-row"], - }, - }, - }, - ]); - - const view = editor.getBlock("d1")?.databaseActiveView(); - expect(view?.visibleColumnIds).toEqual(["name"]); - expect(view?.columnOrder).toEqual(["tags", "name"]); - expect(view?.sort).toEqual([{ columnId: "tags", direction: "asc" }]); - expect(view?.filter).toEqual({ - operator: "and", - conditions: [{ columnId: "tags", operator: "is", value: "todo" }], - }); - expect(view?.groupBy).toBeUndefined(); - expect(view?.rowPinning).toEqual({ - top: ["row-a"], - bottom: undefined, - }); - - editor.destroy(); - }); - - it("database row and select option ops clean up dependent data", () => { - const editor = databaseEditor(); - editor.apply([ - { - type: "database-update-view", - blockId: "d1", - patch: { - rowPinning: { - top: ["row-a"], - bottom: ["row-b"], - }, - }, - }, - { - type: "database-update-column", - blockId: "d1", - columnId: "tags", - patch: { - options: [ - { id: "bug", value: "Bug", color: "red" }, - { id: "chore", value: "Chore", color: "gray" }, - ], - }, - }, - { - type: "database-convert-column", - blockId: "d1", - columnId: "tags", - toType: "multiSelect", - }, - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-a", - values: { - name: "A", - tags: JSON.stringify(["bug", "chore"]), - }, - }, - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-b", - values: { - name: "B", - tags: JSON.stringify(["bug"]), - }, - }, - { - type: "database-update-select-options", - blockId: "d1", - columnId: "tags", - action: "remove", - optionId: "bug", - }, - { - type: "database-duplicate-row", - blockId: "d1", - rowId: "row-a", - newRowId: "row-c", - }, - { - type: "database-delete-rows", - blockId: "d1", - rowIds: ["row-b"], - }, - { - type: "database-move-row", - blockId: "d1", - rowId: "row-c", - index: 0, - }, - ]); - - const block = editor.getBlock("d1")!; - expect(block.tableRowCount()).toBe(2); - expect(block.tableRow(0)?.id).toBe("row-a"); - expect(block.tableRow(1)?.id).toBe("row-c"); - expect(block.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["chore"])); - expect(block.tableCell(1, 1)?.textContent()).toBe(JSON.stringify(["chore"])); - expect(block.tableColumns()[1]?.options).toEqual([ - { id: "chore", value: "Chore", color: "gray" }, - ]); - - editor.apply([ - { - type: "database-remove-column", - blockId: "d1", - columnId: "tags", - }, - ]); - - const nextBlock = editor.getBlock("d1")!; - expect(nextBlock.tableColumns().map((column) => column.id)).toEqual([ - "name", - "status", - ]); - expect(nextBlock.databaseActiveView()?.columnOrder).toEqual([ - "name", - "status", - ]); - expect(nextBlock.databaseActiveView()?.visibleColumnIds).toEqual([ - "name", - "status", - ]); - expect(nextBlock.databaseActiveView()?.rowPinning).toBeUndefined(); - editor.destroy(); - }); - - it("renaming a select option preserves stored option ids", () => { - const editor = databaseEditor(); - editor.apply([ - { - type: "database-update-column", - blockId: "d1", - columnId: "tags", - patch: { - options: [{ id: "todo", value: "Todo", color: "gray" }], - }, - }, - { - type: "database-insert-row", - blockId: "d1", - rowId: "row-1", - values: { - name: "Write docs", - tags: "todo", - }, - }, - { - type: "database-update-select-options", - blockId: "d1", - columnId: "tags", - action: "rename", - optionId: "todo", - value: "Ready", - }, - ]); - - const block = editor.getBlock("d1")!; - expect(block.tableCell(0, 1)?.textContent()).toBe("todo"); - expect(block.tableColumns()[1]?.options).toEqual([ - { id: "todo", value: "Ready", color: "gray" }, - ]); - editor.destroy(); - }); - - it("rejects column type changes through database-update-column", () => { - const editor = databaseEditor(); - editor.apply([{ - type: "database-update-column", - blockId: "d1", - columnId: "name", - patch: { - type: "number", - title: "Name field", - }, - } as DocumentOp]); - - const block = editor.getBlock("d1")!; - expect(block.tableColumns()[0]).toEqual( - expect.objectContaining({ - id: "name", - title: "Name field", - type: "text", - }), - ); - editor.destroy(); - }); - - it("normalizes typed database row writes and rejects invalid updates", () => { - const editor = databaseEditor(); - editor.apply([{ - type: "update-table-columns", - blockId: "d1", - columns: [ - { id: "score", title: "Score", type: "number" }, - { id: "done", title: "Done", type: "checkbox" }, - { - id: "status", - title: "Status", - type: "select", - options: [{ id: "todo", value: "Todo" }], - }, - { - id: "labels", - title: "Labels", - type: "multiSelect", - options: [{ id: "todo", value: "Todo" }], - }, - ], - }]); - - editor.apply([{ - type: "database-insert-row", - blockId: "d1", - rowId: "row-typed", - values: { - score: "not-a-number", - done: "yes", - status: "Todo", - labels: JSON.stringify(["Todo"]), - }, - }]); - - const block = editor.getBlock("d1")!; - expect(block.tableCell(0, 0)?.textContent()).toBe(""); - expect(block.tableCell(0, 1)?.textContent()).toBe("true"); - expect(block.tableCell(0, 2)?.textContent()).toBe("todo"); - expect(block.tableCell(0, 3)?.textContent()).toBe(JSON.stringify(["todo"])); - - editor.apply([{ - type: "database-update-cell", - blockId: "d1", - rowId: "row-typed", - columnId: "score", - value: "42", - }]); - expect(block.tableCell(0, 0)?.textContent()).toBe("42"); - - editor.apply([{ - type: "database-update-cell", - blockId: "d1", - rowId: "row-typed", - columnId: "score", - value: "still-not-a-number", - }]); - expect(block.tableCell(0, 0)?.textContent()).toBe("42"); - - editor.destroy(); - }); - }); diff --git a/packages/core/src/__tests__/databaseOps.part2.test.ts b/packages/core/src/__tests__/databaseOps.part2.test.ts new file mode 100644 index 0000000..71a63fb --- /dev/null +++ b/packages/core/src/__tests__/databaseOps.part2.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "../index"; +import type { DocumentOp } from "@pen/types"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +type RawDatabaseBlockMap = { + get(key: string): unknown; +}; + +type LengthLike = { + length: number; +}; + +function databaseEditor() { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply([ + { + type: "insert-block", + blockId: "d1", + blockType: "database", + props: {}, + position: "last", + }, + ]); + return editor; +} + + +describe("database core operations", () => { + it("normalizes invalid database view references on write", () => { + const editor = databaseEditor(); + + editor.apply([ + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-a", + values: { + name: "Alpha", + tags: "todo", + status: "true", + }, + }, + { + type: "database-update-view", + blockId: "d1", + patch: { + visibleColumnIds: ["name", "missing", "name"], + columnOrder: ["missing", "tags", "name", "tags"], + sort: [ + { columnId: "missing", direction: "asc" }, + { columnId: "tags", direction: "asc" }, + { columnId: "tags", direction: "desc" }, + ], + filter: { + operator: "and", + conditions: [ + { columnId: "missing", operator: "is", value: "x" }, + { columnId: "tags", operator: "is", value: "todo" }, + ], + }, + groupBy: "missing", + rowPinning: { + top: ["missing-row", "row-a", "row-a"], + bottom: ["row-a", "missing-row"], + }, + }, + }, + ]); + + const view = editor.getBlock("d1")?.databaseActiveView(); + expect(view?.visibleColumnIds).toEqual(["name"]); + expect(view?.columnOrder).toEqual(["tags", "name"]); + expect(view?.sort).toEqual([{ columnId: "tags", direction: "asc" }]); + expect(view?.filter).toEqual({ + operator: "and", + conditions: [{ columnId: "tags", operator: "is", value: "todo" }], + }); + expect(view?.groupBy).toBeUndefined(); + expect(view?.rowPinning).toEqual({ + top: ["row-a"], + bottom: undefined, + }); + + editor.destroy(); + }); + + it("database row and select option ops clean up dependent data", () => { + const editor = databaseEditor(); + editor.apply([ + { + type: "database-update-view", + blockId: "d1", + patch: { + rowPinning: { + top: ["row-a"], + bottom: ["row-b"], + }, + }, + }, + { + type: "database-update-column", + blockId: "d1", + columnId: "tags", + patch: { + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "chore", value: "Chore", color: "gray" }, + ], + }, + }, + { + type: "database-convert-column", + blockId: "d1", + columnId: "tags", + toType: "multiSelect", + }, + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-a", + values: { + name: "A", + tags: JSON.stringify(["bug", "chore"]), + }, + }, + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-b", + values: { + name: "B", + tags: JSON.stringify(["bug"]), + }, + }, + { + type: "database-update-select-options", + blockId: "d1", + columnId: "tags", + action: "remove", + optionId: "bug", + }, + { + type: "database-duplicate-row", + blockId: "d1", + rowId: "row-a", + newRowId: "row-c", + }, + { + type: "database-delete-rows", + blockId: "d1", + rowIds: ["row-b"], + }, + { + type: "database-move-row", + blockId: "d1", + rowId: "row-c", + index: 0, + }, + ]); + + const block = editor.getBlock("d1")!; + expect(block.tableRowCount()).toBe(2); + expect(block.tableRow(0)?.id).toBe("row-a"); + expect(block.tableRow(1)?.id).toBe("row-c"); + expect(block.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["chore"])); + expect(block.tableCell(1, 1)?.textContent()).toBe(JSON.stringify(["chore"])); + expect(block.tableColumns()[1]?.options).toEqual([ + { id: "chore", value: "Chore", color: "gray" }, + ]); + + editor.apply([ + { + type: "database-remove-column", + blockId: "d1", + columnId: "tags", + }, + ]); + + const nextBlock = editor.getBlock("d1")!; + expect(nextBlock.tableColumns().map((column) => column.id)).toEqual([ + "name", + "status", + ]); + expect(nextBlock.databaseActiveView()?.columnOrder).toEqual([ + "name", + "status", + ]); + expect(nextBlock.databaseActiveView()?.visibleColumnIds).toEqual([ + "name", + "status", + ]); + expect(nextBlock.databaseActiveView()?.rowPinning).toBeUndefined(); + editor.destroy(); + }); + + it("renaming a select option preserves stored option ids", () => { + const editor = databaseEditor(); + editor.apply([ + { + type: "database-update-column", + blockId: "d1", + columnId: "tags", + patch: { + options: [{ id: "todo", value: "Todo", color: "gray" }], + }, + }, + { + type: "database-insert-row", + blockId: "d1", + rowId: "row-1", + values: { + name: "Write docs", + tags: "todo", + }, + }, + { + type: "database-update-select-options", + blockId: "d1", + columnId: "tags", + action: "rename", + optionId: "todo", + value: "Ready", + }, + ]); + + const block = editor.getBlock("d1")!; + expect(block.tableCell(0, 1)?.textContent()).toBe("todo"); + expect(block.tableColumns()[1]?.options).toEqual([ + { id: "todo", value: "Ready", color: "gray" }, + ]); + editor.destroy(); + }); + + it("rejects column type changes through database-update-column", () => { + const editor = databaseEditor(); + editor.apply([{ + type: "database-update-column", + blockId: "d1", + columnId: "name", + patch: { + type: "number", + title: "Name field", + }, + } as DocumentOp]); + + const block = editor.getBlock("d1")!; + expect(block.tableColumns()[0]).toEqual( + expect.objectContaining({ + id: "name", + title: "Name field", + type: "text", + }), + ); + editor.destroy(); + }); + + it("normalizes typed database row writes and rejects invalid updates", () => { + const editor = databaseEditor(); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "score", title: "Score", type: "number" }, + { id: "done", title: "Done", type: "checkbox" }, + { + id: "status", + title: "Status", + type: "select", + options: [{ id: "todo", value: "Todo" }], + }, + { + id: "labels", + title: "Labels", + type: "multiSelect", + options: [{ id: "todo", value: "Todo" }], + }, + ], + }]); + + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "row-typed", + values: { + score: "not-a-number", + done: "yes", + status: "Todo", + labels: JSON.stringify(["Todo"]), + }, + }]); + + const block = editor.getBlock("d1")!; + expect(block.tableCell(0, 0)?.textContent()).toBe(""); + expect(block.tableCell(0, 1)?.textContent()).toBe("true"); + expect(block.tableCell(0, 2)?.textContent()).toBe("todo"); + expect(block.tableCell(0, 3)?.textContent()).toBe(JSON.stringify(["todo"])); + + editor.apply([{ + type: "database-update-cell", + blockId: "d1", + rowId: "row-typed", + columnId: "score", + value: "42", + }]); + expect(block.tableCell(0, 0)?.textContent()).toBe("42"); + + editor.apply([{ + type: "database-update-cell", + blockId: "d1", + rowId: "row-typed", + columnId: "score", + value: "still-not-a-number", + }]); + expect(block.tableCell(0, 0)?.textContent()).toBe("42"); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part1.test.ts b/packages/core/src/__tests__/editorCore.part1.test.ts new file mode 100644 index 0000000..93c3f24 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part1.test.ts @@ -0,0 +1,412 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("warns once when using the deprecated without option", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const editor = createCoreEditor({ + without: ["document-ops"], + }); + editor.destroy(); + + expect(warnSpy).toHaveBeenCalledWith( + "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", + ); + + warnSpy.mockRestore(); + }); + + it("installs extensions from presets before user extensions", () => { + const editor = createEditor({ + preset: { + resolve() { + return { + extensions: [ + defineExtension({ + name: "preset-test-extension", + activateClient: async (ctx) => { + ctx.editor.internals.setSlot( + "test:preset-installed", + true, + ); + }, + }), + ], + }; + }, + }, + }); + + expect(editor.internals.getSlot("test:preset-installed")).toBe(true); + + editor.destroy(); + }); + + it("supports multiple editors sharing one document session", () => { + const session = createDocumentSession({ + adapter: yjsAdapter(), + }); + const editorA = createEditor({ + documentSession: session, + }); + const editorB = createEditor({ + documentSession: session, + }); + const blockId = editorA.firstBlock()!.id; + + editorA.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Shared", + }, + ]); + + expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared"); + expect(editorA.documentScope.id).toBe(editorB.documentScope.id); + expect(editorA.internals.documentSession).toBe(session); + expect(editorB.internals.documentSession).toBe(session); + + editorA.destroy(); + editorB.apply([ + { + type: "insert-text", + blockId, + offset: 6, + text: " doc", + }, + ]); + + expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared doc"); + + editorB.destroy(); + session.destroy(); + }); + + it("creates headless editors around caller-owned documents without default undo behavior", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editor = createHeadlessEditor({ crdt: adapter, document }); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Server edit", + }, + ]); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Server edit"); + expect(editor.undoManager.undo()).toBe(false); + + editor.destroy(); + }); + + it("does not destroy caller-owned documents on editor teardown", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editorA = createEditor({ + document, + }); + const blockId = editorA.firstBlock()!.id; + + editorA.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Persisted", + }, + ]); + editorA.destroy(); + + const editorB = createEditor({ + document, + }); + + expect(editorB.getBlock(blockId)?.textContent()).toBe("Persisted"); + + editorB.destroy(); + }); + + it("persists document profile metadata for new editors", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + + expect(editor.documentProfile).toBe("flow"); + expect(editor.documentState.documentProfile).toBe("flow"); + expect(editor.editorViewMode).toBe("flow"); + expect( + editor.internals.adapter.getDocumentProfile?.( + editor.internals.crdtDoc, + ), + ).toBe("flow"); + + editor.destroy(); + }); + + it("loads persisted document profile independently from local editor view mode", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + adapter.setDocumentProfile?.(document, "flow"); + + const editor = createEditor({ + document, + editorViewMode: "structured", + }); + + expect(editor.documentProfile).toBe("flow"); + expect(editor.documentState.documentProfile).toBe("flow"); + expect(editor.editorViewMode).toBe("structured"); + + editor.destroy(); + }); + + it("keeps document profile in sync with persisted metadata changes", () => { + const adapter = yjsAdapter(); + const document = adapter.createDocument(); + const editor = createEditor({ + document, + }); + + expect(editor.documentProfile).toBe("structured"); + expect(editor.documentState.documentProfile).toBe("structured"); + + adapter.setDocumentProfile?.(document, "flow"); + + expect(editor.documentProfile).toBe("flow"); + expect(editor.documentState.documentProfile).toBe("flow"); + expect(editor.editorViewMode).toBe("flow"); + + editor.destroy(); + }); + + it("drops flow-disallowed block insertions at the mutation boundary", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "db1", + blockType: "database", + props: {}, + position: "last", + }, + ]); + + expect(editor.getBlock("db1")).toBeNull(); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_PROFILE_001", + level: "warn", + source: "profile-policy", + blockType: "database", + documentProfile: "flow", + }), + ); + + editor.destroy(); + }); + + it("re-applies the flow mutation boundary after extension hooks run", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.onBeforeApply( + (ops) => [ + ...ops, + { + type: "insert-block", + blockId: "db-after-hook", + blockType: "database", + props: {}, + position: "last", + }, + ], + { priority: 20000 }, + ); + + editor.apply([ + { + type: "insert-block", + blockId: "p-after-hook", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + expect(editor.getBlock("p-after-hook")?.type).toBe("paragraph"); + expect(editor.getBlock("db-after-hook")).toBeNull(); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_PROFILE_001", + blockType: "database", + documentProfile: "flow", + }), + ); + + editor.destroy(); + }); + + it("drops flow-disallowed block conversions at the mutation boundary", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + const firstBlockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "Hello", + }, + ]); + + editor.apply([ + { + type: "convert-block", + blockId: firstBlockId, + newType: "database", + newProps: {}, + }, + ]); + + expect(editor.getBlock(firstBlockId)?.type).toBe("paragraph"); + expect(editor.getBlock(firstBlockId)?.textContent()).toBe("Hello"); + + editor.destroy(); + }); + + it("still allows optional structural blocks in flow documents", () => { + const editor = createEditor({ + documentProfile: "flow", + }); + + editor.apply([ + { + type: "insert-block", + blockId: "table1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + expect(editor.getBlock("table1")?.type).toBe("table"); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part2.test.ts b/packages/core/src/__tests__/editorCore.part2.test.ts new file mode 100644 index 0000000..89654c6 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part2.test.ts @@ -0,0 +1,438 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 8): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("discovers subdocument scopes and lets nested editors edit them", () => { + const session = createDocumentSession({ + adapter: yjsAdapter(), + }); + const rootEditor = createEditor({ + documentSession: session, + }); + + rootEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested" }, + position: "last", + }, + ]); + + const childScope = session.getScopeForBlock("subdoc-block", { + scopeId: rootEditor.documentScope.id, + }); + expect(childScope).not.toBeNull(); + expect(rootEditor.getBlock("subdoc-block")?.props.subdocumentGuid).toBe( + childScope?.guid, + ); + + const childEditor = createEditor({ + documentSession: session, + documentScopeId: childScope!.id, + }); + const childBlockId = childEditor.firstBlock()!.id; + + childEditor.apply([ + { + type: "insert-text", + blockId: childBlockId, + offset: 0, + text: "Nested content", + }, + ]); + + expect(childEditor.getBlock(childBlockId)?.textContent()).toBe( + "Nested content", + ); + expect(childEditor.documentScope.parentId).toBe( + rootEditor.documentScope.id, + ); + expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); + + childEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested Nested" }, + position: "last", + }, + ]); + + const nestedScope = session.getScopeForBlock("subdoc-block", { + scopeId: childEditor.documentScope.id, + }); + expect(nestedScope).not.toBeNull(); + expect(nestedScope?.id).not.toBe(childScope?.id); + expect(session.getScopeForBlock("subdoc-block")).toBeNull(); + + childEditor.destroy(); + rootEditor.destroy(); + session.destroy(); + }); + + it("supports delegated document session implementations for scope replacement", async () => { + const baseSession = createDocumentSession({ + adapter: yjsAdapter(), + }); + const delegatedSession: DocumentSession = { + adapter: baseSession.adapter, + get rootScope() { + return baseSession.rootScope; + }, + getScope: (scopeId) => baseSession.getScope(scopeId), + getScopeByGuid: (guid) => baseSession.getScopeByGuid(guid), + getScopeForBlock: (blockId, options) => + baseSession.getScopeForBlock(blockId, options), + listScopes: () => baseSession.listScopes(), + getAwareness: (scopeId) => baseSession.getAwareness(scopeId), + observe: (scopeId, callback) => + baseSession.observe(scopeId, callback), + observeAll: (callback) => baseSession.observeAll(callback), + createSubdocument: (blockId, options) => + baseSession.createSubdocument(blockId, options), + loadSubdocument: (scopeId) => baseSession.loadSubdocument(scopeId), + replaceScopeDocument: (scopeId, doc, options) => + baseSession.replaceScopeDocument(scopeId, doc, options), + attachEditor: (options) => baseSession.attachEditor(options), + destroy: () => baseSession.destroy(), + }; + const editor = createEditor({ + documentSession: delegatedSession, + }); + const originalDoc = editor.internals.crdtDoc; + const replacementSource = createEditor(); + const replacementDoc = delegatedSession.adapter.loadDocument( + delegatedSession.adapter.encodeState( + replacementSource.internals.crdtDoc, + ), + ); + + delegatedSession.replaceScopeDocument( + editor.documentScope.id, + replacementDoc, + ); + await flushMicrotasks(); + + expect(editor.internals.crdtDoc).toBe(replacementDoc); + expect(editor.internals.crdtDoc).not.toBe(originalDoc); + expect(editor.firstBlock()).not.toBeNull(); + + replacementSource.destroy(); + editor.destroy(); + delegatedSession.destroy(); + }); + + it("rebinds child-scope editors when the root session document is replaced", async () => { + const session = createDocumentSession({ + adapter: yjsAdapter(), + }); + const rootEditor = createEditor({ + documentSession: session, + }); + rootEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested" }, + position: "last", + }, + ]); + const childScope = session.getScopeForBlock("subdoc-block", { + scopeId: rootEditor.documentScope.id, + }); + const childEditor = createEditor({ + documentSession: session, + documentScopeId: childScope!.id, + }); + const childBlockId = childEditor.firstBlock()!.id; + childEditor.apply([ + { + type: "insert-text", + blockId: childBlockId, + offset: 0, + text: "Original nested content", + }, + ]); + + const replacementSession = createDocumentSession({ + adapter: yjsAdapter(), + ownsDocuments: false, + }); + const replacementRootEditor = createEditor({ + documentSession: replacementSession, + }); + replacementRootEditor.apply([ + { + type: "insert-block", + blockId: "subdoc-block", + blockType: "subdocument", + props: { title: "Nested" }, + position: "last", + }, + ]); + const replacementChildScope = replacementSession.getScopeForBlock( + "subdoc-block", + { + scopeId: replacementRootEditor.documentScope.id, + }, + ); + const replacementChildEditor = createEditor({ + documentSession: replacementSession, + documentScopeId: replacementChildScope!.id, + }); + const replacementChildBlockId = replacementChildEditor.firstBlock()!.id; + replacementChildEditor.apply([ + { + type: "insert-text", + blockId: replacementChildBlockId, + offset: 0, + text: "Replacement nested content", + }, + ]); + + session.replaceScopeDocument( + rootEditor.documentScope.id, + replacementSession.rootScope.doc, + ); + await flushMicrotasks(); + + expect(childEditor.firstBlock()?.textContent()).toBe( + "Replacement nested content", + ); + expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); + expect(childEditor.documentScope.parentId).toBe( + rootEditor.documentScope.id, + ); + + replacementChildEditor.destroy(); + replacementRootEditor.destroy(); + replacementSession.destroy(); + childEditor.destroy(); + rootEditor.destroy(); + session.destroy(); + }); + + it("creates a working editor with default schema and extensions", () => { + const editor = createDefaultEditor(); + + expect(editor.schema.resolve("paragraph")).toBeTruthy(); + expect(typeof editor.clientId).toBe("number"); + expect(editor.internals.getSlot("core:engine")).toBe( + editor.internals.engine, + ); + expect( + editor.internals.getSlot("document-ops:toolRuntime"), + ).toBeTruthy(); + expect(editor.internals.getSlot("undo:manager")).toBeTruthy(); + + editor.destroy(); + }); + + it("starts with a single empty paragraph block in zero-config mode", () => { + const editor = createDefaultEditor(); + + expect(editor.blockCount()).toBe(1); + expect(editor.firstBlock()?.type).toBe("paragraph"); + expect(editor.firstBlock()?.textContent()).toBe(""); + + editor.destroy(); + }); + + it("applies insert-block and insert-text operations", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello"); + + editor.destroy(); + }); + + it("moves the text selection after accepting an inline completion", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + const { controller } = ensureInlineCompletionController(editor); + + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + editor.selectText(blockId, 5, 5); + controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 5, + text: " world", + type: "inline", + }); + + expect(controller.acceptSuggestion()).toBe(true); + + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world"); + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 11 }, + focus: { blockId, offset: 11 }, + }); + + editor.destroy(); + }); + + it("splits and merges inline blocks", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello world", + }, + ]); + + editor.apply([ + { + type: "split-block", + blockId: "b1", + offset: 5, + newBlockId: "b2", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello"); + expect(editor.getBlock("b2")?.textContent()).toBe(" world"); + + editor.apply([ + { + type: "merge-blocks", + targetBlockId: "b1", + sourceBlockId: "b2", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello world"); + expect(editor.getBlock("b2")).toBeNull(); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part3.test.ts b/packages/core/src/__tests__/editorCore.part3.test.ts new file mode 100644 index 0000000..f57152f --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part3.test.ts @@ -0,0 +1,411 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("splits at offset zero by inserting an empty block above", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "hello world", + }, + ]); + + editor.apply([ + { + type: "split-block", + blockId, + offset: 0, + newBlockId: "b2", + }, + ]); + + expect(editor.documentState.blockOrder).toEqual([blockId, "b2"]); + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.getBlock("b2")?.textContent()).toBe("hello world"); + + editor.destroy(); + }); + + it("preserves full text offsets for code blocks", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + editor.apply([ + { type: "convert-block", blockId, newType: "codeBlock" }, + { type: "insert-text", blockId, offset: 0, text: "abcd" }, + ]); + + editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 3 }); + + expect(editor.selection).toMatchObject({ + type: "text", + anchor: { blockId, offset: 1 }, + focus: { blockId, offset: 3 }, + }); + expect(editor.getSelectedText()).toBe("bc"); + + editor.destroy(); + }); + + it("clears stale grid state when converting table or database blocks", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "table-block", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "database-block", + blockType: "database", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "database-insert-row", + blockId: "database-block", + rowId: "row-1", + values: { + name: "Alpha", + tags: "todo", + status: "true", + }, + }, + { + type: "convert-block", + blockId: "table-block", + newType: "paragraph", + }, + { + type: "convert-block", + blockId: "database-block", + newType: "paragraph", + }, + ]); + + const tableBlock = editor.getBlock("table-block")!; + const databaseBlock = editor.getBlock("database-block")!; + expect(tableBlock.type).toBe("paragraph"); + expect(tableBlock.tableRowCount()).toBe(0); + expect(tableBlock.tableColumns()).toEqual([]); + expect(tableBlock.databaseViews()).toEqual([]); + + expect(databaseBlock.type).toBe("paragraph"); + expect(databaseBlock.tableRowCount()).toBe(0); + expect(databaseBlock.tableColumns()).toEqual([]); + expect(databaseBlock.databaseViews()).toEqual([]); + expect(databaseBlock.databasePrimaryViewId()).toBeNull(); + + const tableBlockMap = editor.internals.doc.blocks.get( + "table-block", + ) as TestBlockMapLike; + const databaseBlockMap = editor.internals.doc.blocks.get( + "database-block", + ) as TestBlockMapLike; + expect(tableBlockMap.get("tableContent")).toBeUndefined(); + expect(tableBlockMap.get("tableColumns")).toBeUndefined(); + expect(tableBlockMap.get("databaseViews")).toBeUndefined(); + expect(tableBlockMap.get("databasePrimaryViewId")).toBeUndefined(); + expect(databaseBlockMap.get("tableContent")).toBeUndefined(); + expect(databaseBlockMap.get("tableColumns")).toBeUndefined(); + expect(databaseBlockMap.get("databaseViews")).toBeUndefined(); + expect(databaseBlockMap.get("databasePrimaryViewId")).toBeUndefined(); + + editor.destroy(); + }); + + it("queues reentrant apply calls from observe hooks", () => { + let appended = false; + const ext = defineExtension({ + name: "append-exclamation", + observe(events, editor) { + if (appended) return; + const hasInsertText = events.some((event) => + event.ops.some((op) => op.type === "insert-text"), + ); + if (!hasInsertText) return; + + appended = true; + editor.apply( + [ + { + type: "insert-text", + blockId: "b1", + offset: 5, + text: "!", + }, + ], + { origin: "extension" }, + ); + }, + }); + + const editor = createEditor({ + extensions: [ext], + }); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello", + }, + ]); + + expect(editor.getBlock("b1")?.textContent()).toBe("hello!"); + + editor.destroy(); + }); + + it("activates input-rules extensions and applies block conversions", async () => { + const editor = createEditor({ + extensions: [inputRulesExtension()], + }); + const blockId = editor.firstBlock()!.id; + + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 0 }); + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "#", + }, + ], + { origin: "user" }, + ); + editor.selectTextRange({ blockId, offset: 1 }, { blockId, offset: 1 }); + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 1, + text: " ", + }, + ], + { origin: "user" }, + ); + await flushMicrotasks(); + + expect(editor.getBlock(blockId)?.type).toBe("heading"); + expect(editor.getBlock(blockId)?.props.level).toBe(1); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); + + editor.destroy(); + }); + + it("activates input-rules extensions and applies inline markdown conversions", async () => { + const editor = createEditor({ + extensions: [inputRulesExtension()], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "**hello*", + }, + ], + { origin: "user" }, + ); + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 8, + text: "*", + }, + ], + { origin: "user" }, + ); + await flushMicrotasks(); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "hello", + ); + expect(editor.getBlock(blockId)?.textDeltas()).toEqual([ + { + insert: "hello", + attributes: { bold: true }, + }, + ]); + + editor.destroy(); + }); + + it("emits unified change and documentCommit once for a local apply batch", () => { + const observed: unknown[][] = []; + const ext = defineExtension({ + name: "capture-local-dispatch", + observe(events) { + observed.push(events); + }, + }); + const editor = createEditor({ + extensions: [ext], + }); + const changes: unknown[][] = []; + const documentCommits: unknown[] = []; + const blockId = editor.firstBlock()!.id; + + editor.on("change", (events) => { + changes.push(events); + }); + editor.on("documentCommit", (event) => { + documentCommits.push(event); + }); + observed.length = 0; + changes.length = 0; + documentCommits.length = 0; + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "hello", + }, + ]); + + expect(changes).toHaveLength(1); + expect(changes[0]).toHaveLength(1); + expect(changes[0][0]).toMatchObject({ + origin: "user", + affectedBlocks: [blockId], + }); + expect(documentCommits).toHaveLength(1); + expect(documentCommits[0]).toMatchObject({ + commitId: 2, + origin: "user", + affectedBlocks: [blockId], + }); + expect( + (documentCommits[0] as { blockRevisions: Record }) + .blockRevisions[blockId], + ).toBe(editor.getBlockRevision(blockId)); + expect(observed).toHaveLength(1); + expect(observed[0]).toHaveLength(1); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part4.test.ts b/packages/core/src/__tests__/editorCore.part4.test.ts new file mode 100644 index 0000000..22aaf62 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part4.test.ts @@ -0,0 +1,447 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("emits unified change and documentCommit once for observed CRDT updates", () => { + const observed: unknown[][] = []; + const ext = defineExtension({ + name: "capture-observed-dispatch", + observe(events) { + observed.push(events); + }, + }); + const editor = createEditor({ + extensions: [ext], + }); + const changes: unknown[][] = []; + const documentCommits: unknown[] = []; + const adapter = editor.internals.adapter; + const editorDoc = editor.internals.crdtDoc; + const blockId = editor.firstBlock()!.id; + const remoteDoc = adapter.loadDocument(adapter.encodeState(editorDoc)); + const remoteYDoc = adapter.raw(remoteDoc); + const remoteYText = remoteYDoc + .getMap("blocks") + .get(blockId) + ?.get("content") as TestYTextLike | undefined; + if (!remoteYText) { + throw new Error(`Missing collaborator text for block ${blockId}`); + } + + editor.on("change", (events) => { + changes.push(events); + }); + editor.on("documentCommit", (event) => { + documentCommits.push(event); + }); + observed.length = 0; + changes.length = 0; + documentCommits.length = 0; + + adapter.transact( + remoteDoc, + () => { + remoteYText.insert(0, "remote "); + }, + "collaborator", + ); + adapter.applyUpdate(editorDoc, adapter.encodeState(remoteDoc)); + + expect(changes).toHaveLength(1); + expect(changes[0]).toHaveLength(1); + expect(changes[0][0]).toMatchObject({ + affectedBlocks: [blockId], + }); + expect(documentCommits).toHaveLength(1); + expect(documentCommits[0]).toMatchObject({ + commitId: 2, + affectedBlocks: [blockId], + }); + expect( + (documentCommits[0] as { blockRevisions: Record }) + .blockRevisions[blockId], + ).toBe(editor.getBlockRevision(blockId)); + expect(observed).toHaveLength(1); + expect(observed[0]).toHaveLength(1); + + editor.destroy(); + }); + + it("clamps text selections and returns backwards selected text", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "hello", + }, + ]); + + editor.selectText("b1", 10, 99); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 5 }, + focus: { blockId: "b1", offset: 5 }, + }); + + editor.setSelection({ + type: "text", + anchor: { blockId: "b1", offset: 5 }, + focus: { blockId: "b1", offset: 2 }, + isCollapsed: false, + isMultiBlock: false, + blockRange: ["b1"], + toRange: () => { + throw new Error("test helper"); + }, + }); + + expect(editor.getSelectedText()).toBe("llo"); + + editor.destroy(); + }); + + it("selects text ranges across blocks in document order", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 3 }, + ); + + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 2 }, + focus: { blockId: "b3", offset: 3 }, + isMultiBlock: true, + blockRange: ["b1", "b2", "b3"], + }); + expect(editor.getSelectedText()).toBe("llo\nWorld\nAga"); + expect(editor.getSelectedBlocks().map((block) => block.id)).toEqual([ + "b1", + "b2", + "b3", + ]); + + editor.destroy(); + }); + + it("deletes multi-block text selections and collapses at the start", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("b1")?.textContent()).toBe("Heain"); + expect(editor.getBlock("b2")).toBeNull(); + expect(editor.getBlock("b3")).toBeNull(); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 2 }, + focus: { blockId: "b1", offset: 2 }, + isMultiBlock: false, + blockRange: ["b1"], + }); + + editor.destroy(); + }); + + it("deletes a fully selected structural block", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "d1", + blockType: "divider", + props: {}, + position: "last", + }, + ]); + + editor.selectTextRange( + { blockId: "d1", offset: 0 }, + { blockId: "d1", offset: 1 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("d1")).toBeNull(); + expect(editor.getSelection()).toBeNull(); + + editor.destroy(); + }); + + it("deletes a fully selected delegated block", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.selectTextRange( + { blockId: "t1", offset: 0 }, + { blockId: "t1", offset: 1 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("t1")).toBeNull(); + expect(editor.getSelection()).toBeNull(); + + editor.destroy(); + }); + + it("deletes structural blocks at multi-block selection boundaries", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "p1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "d1", + blockType: "divider", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "p1", offset: 0, text: "Hello" }, + ]); + + editor.selectTextRange( + { blockId: "p1", offset: 2 }, + { blockId: "d1", offset: 1 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("p1")?.textContent()).toBe("He"); + expect(editor.getBlock("d1")).toBeNull(); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "p1", offset: 2 }, + focus: { blockId: "p1", offset: 2 }, + isMultiBlock: false, + blockRange: ["p1"], + }); + + editor.destroy(); + }); + + it("replaces multi-block text selections at a single insertion point", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + editor.replaceSelection("X"); + + expect(editor.getBlock("b1")?.textContent()).toBe("HeXain"); + expect(editor.getBlock("b2")).toBeNull(); + expect(editor.getBlock("b3")).toBeNull(); + expect(editor.getSelection()).toMatchObject({ + type: "text", + anchor: { blockId: "b1", offset: 3 }, + focus: { blockId: "b1", offset: 3 }, + isMultiBlock: false, + blockRange: ["b1"], + }); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part5.test.ts b/packages/core/src/__tests__/editorCore.part5.test.ts new file mode 100644 index 0000000..855a5fb --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part5.test.ts @@ -0,0 +1,434 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("preserves formatted suffix text when deleting across blocks", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "Again" }, + { + type: "format-text", + blockId: "b2", + offset: 2, + length: 3, + marks: { bold: true }, + }, + ]); + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b2", offset: 2 }, + ); + editor.deleteSelection(); + + expect(editor.getBlock("b1")?.textDeltas()).toEqual([ + { insert: "He" }, + { + insert: "ain", + attributes: { bold: true }, + }, + ]); + expect(editor.getBlock("b2")).toBeNull(); + + editor.destroy(); + }); + + it("replaces multi-block text selections in a single document commit batch", () => { + const editor = createEditor(); + const events: Array<{ ops: readonly { type: string }[] }> = []; + + editor.on("documentCommit", (event) => { + events.push(event as { ops: readonly { type: string }[] }); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + events.length = 0; + + editor.selectTextRange( + { blockId: "b1", offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + editor.replaceSelection("X"); + + expect(events).toHaveLength(1); + expect(events[0]?.ops.map((op) => op.type)).toEqual([ + "delete-text", + "delete-text", + "delete-block", + "insert-text", + "insert-text", + "delete-block", + ]); + + editor.destroy(); + }); + + it("rebinds undo manager after loadDocument", async () => { + const editor = createDefaultEditor(); + const newDoc = editor.internals.adapter.createDocument(); + + editor.loadDocument(newDoc); + await flushMicrotasks(); + + expect(editor.undoManager).toBe( + editor.internals.getSlot("undo:manager"), + ); + + editor.destroy(); + }); + + it("waits for async extension teardown before reactivating after loadDocument", async () => { + const steps: string[] = []; + let activationCount = 0; + let resolveDeactivate!: () => void; + const deactivatePromise = new Promise((resolve) => { + resolveDeactivate = resolve; + }); + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "async-lifecycle", + activateClient: async () => { + activationCount += 1; + steps.push(`activate:${activationCount}`); + }, + deactivateClient: async () => { + steps.push("deactivate:start"); + await deactivatePromise; + steps.push("deactivate:end"); + }, + }), + ], + }); + + await flushMicrotasks(); + + editor.loadDocument(editor.internals.adapter.createDocument()); + await flushMicrotasks(); + + expect(steps).toEqual(["activate:1", "deactivate:start"]); + + resolveDeactivate(); + await flushMicrotasks(4); + + expect(steps).toEqual([ + "activate:1", + "deactivate:start", + "deactivate:end", + "activate:2", + ]); + + editor.destroy(); + }); + + it("refreshes editor.undoManager immediately when the undo slot is set", async () => { + const registeredUndoManager = { + undo: () => false, + redo: () => false, + canUndo: () => false, + canRedo: () => false, + stopCapturing: () => {}, + syncExplicitUndoGroup: () => {}, + setGroupTimeout: () => {}, + registerTrackedOrigins: () => () => {}, + onStackChange: () => () => {}, + }; + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "test-undo-slot", + activateClient: async ({ editor }) => { + expect(editor.undoManager).not.toBe( + registeredUndoManager, + ); + editor.internals.setSlot( + "undo:manager", + registeredUndoManager, + ); + expect(editor.undoManager).toBe(registeredUndoManager); + }, + }), + ], + }); + + await Promise.resolve(); + + expect(editor.undoManager).toBe(registeredUndoManager); + + editor.destroy(); + }); + + it("updates documentState parent relationships after parentId changes", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "parent", + blockType: "toggle", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "child", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "update-block", + blockId: "child", + props: { parentId: "parent" }, + }, + ]); + + expect(editor.documentState.parentOf("child")).toBe("parent"); + + editor.apply([ + { + type: "update-block", + blockId: "child", + props: { parentId: null }, + }, + ]); + + expect(editor.documentState.parentOf("child")).toBeNull(); + + editor.destroy(); + }); + + it("emits structured diagnostics for unknown block types", () => { + const editor = createEditor(); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "unknown", + blockType: "not-real", + props: {}, + position: "last", + }, + ]); + + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + }), + ); + + editor.destroy(); + }); + + it("emits remediation text for extension observe failures", () => { + const diagnostics: unknown[] = []; + const ext = defineExtension({ + name: "broken-observe", + observe() { + throw new Error("boom"); + }, + }); + const editor = createEditor({ + extensions: [ext], + }); + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_EXT_001", + level: "error", + source: "extension", + remediation: expect.any(String), + }), + ); + + editor.destroy(); + }); + + it("emits diagnostics for rejected async extension activation", async () => { + const diagnostics: unknown[] = []; + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "broken-async-activate", + activateClient: async () => { + await Promise.resolve(); + throw new Error("boom"); + }, + }), + ], + }); + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + await flushMicrotasks(4); + + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "PEN_EXT_004", + level: "error", + source: "extension", + extension: "broken-async-activate", + remediation: expect.any(String), + }), + ); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part6.test.ts b/packages/core/src/__tests__/editorCore.part6.test.ts new file mode 100644 index 0000000..160643c --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part6.test.ts @@ -0,0 +1,382 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core createEditor", () => { + it("processes streamed AI deltas through the default delta-stream pipeline", async () => { + const editor = createDefaultEditor(); + const blockId = editor.firstBlock()!.id; + + await processStream( + createStream([ + { type: "gen-start", zoneId: "zone-1", blockId }, + { type: "gen-delta", zoneId: "zone-1", delta: "Hello " }, + { type: "gen-delta", zoneId: "zone-1", delta: "world" }, + { type: "gen-end", zoneId: "zone-1", status: "complete" }, + ]), + editor, + ); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "Hello world", + ); + expect( + editor.internals.getSlot<{ generationZone: unknown }>( + "delta-stream:target", + )?.generationZone ?? null, + ).toBeNull(); + + editor.destroy(); + }); + + it("keeps streamed AI generations in their own undo group", async () => { + const editor = createDefaultEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + + editor.apply( + [ + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "system" }, + ); + + editor.apply( + [ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "hello", + }, + ], + { origin: "user" }, + ); + + await processStream( + createStream([ + { type: "gen-start", zoneId: "zone-2", blockId: secondBlockId }, + { type: "gen-delta", zoneId: "zone-2", delta: "AI output" }, + { type: "gen-end", zoneId: "zone-2", status: "complete" }, + ]), + editor, + ); + + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "hello", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "hello", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + expect(editor.undoManager.redo()).toBe(true); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + editor.destroy(); + }); + + it("keeps concurrent user edits outside the generation zone in a separate undo group", async () => { + const editor = createDefaultEditor(); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + + editor.apply( + [ + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "system" }, + ); + + await processStream( + (async function* (): AsyncIterable { + yield { + type: "gen-start", + zoneId: "zone-concurrent", + blockId: secondBlockId, + }; + + editor.apply( + [ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "user edit", + }, + ], + { origin: "user" }, + ); + + yield { + type: "gen-delta", + zoneId: "zone-concurrent", + delta: "AI output", + }; + yield { + type: "gen-end", + zoneId: "zone-concurrent", + status: "complete", + }; + })(), + editor, + ); + + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "user edit", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "user edit", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + expect(editor.undoManager.redo()).toBe(true); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( + "", + ); + expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( + "", + ); + + editor.destroy(); + }); + + it("keeps user edits inside the generation zone in the same undo group", async () => { + const editor = createDefaultEditor(); + const blockId = editor.firstBlock()!.id; + + await processStream( + (async function* (): AsyncIterable { + yield { type: "gen-start", zoneId: "zone-shared", blockId }; + yield { + type: "gen-delta", + zoneId: "zone-shared", + delta: "AI ", + }; + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 3, + text: "user ", + }, + ], + { origin: "user" }, + ); + + yield { + type: "gen-delta", + zoneId: "zone-shared", + delta: "output", + }; + yield { + type: "gen-end", + zoneId: "zone-shared", + status: "complete", + }; + })(), + editor, + ); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "user AI output", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); + + expect(editor.undoManager.redo()).toBe(true); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "user AI output", + ); + + editor.destroy(); + }); + + it("tracks imported edits in the undo stack", () => { + const editor = createEditorWithUndo(); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "Imported text", + }, + ], + { origin: "import", undoGroup: true }, + ); + + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( + "Imported text", + ); + expect(editor.undoManager.undo()).toBe(true); + expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); + + editor.destroy(); + }); + + it("emits history origin for undo transactions on documentCommit", () => { + const editor = createEditorWithUndo(); + const blockId = editor.firstBlock()!.id; + const commitOrigins: string[] = []; + + editor.on("documentCommit", (event) => { + commitOrigins.push(getOpOriginType(event.origin)); + }); + + editor.apply([ + { + type: "insert-text", + blockId, + offset: 0, + text: "Hello", + }, + ]); + + editor.undoManager.undo(); + + expect(commitOrigins).toContain("user"); + expect(commitOrigins).toContain("history"); + + editor.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/editorCore.part7.test.ts b/packages/core/src/__tests__/editorCore.part7.test.ts new file mode 100644 index 0000000..98d9204 --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part7.test.ts @@ -0,0 +1,428 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core table operations", () => { + it("insert-block with table type produces seeded 2x2 grid", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.type).toBe("table"); + expect(block.tableRowCount()).toBe(2); + expect(block.tableColumnCount()).toBe(2); + + const cell = block.tableCell(0, 0)!; + expect(cell).not.toBeNull(); + expect(cell.id).toEqual(expect.any(String)); + expect(cell.textContent()).toBe(""); + + editor.destroy(); + }); + + it("insert-table-row adds a row matching existing column count", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-row", + blockId: "t1", + index: 2, + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.tableRowCount()).toBe(3); + expect(block.tableColumnCount()).toBe(2); + expect(block.tableCell(2, 0)).not.toBeNull(); + expect(block.tableCell(2, 1)).not.toBeNull(); + + editor.destroy(); + }); + + it("repairs table width from the widest row when legacy rows are short", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-column", + blockId: "t1", + index: 2, + }, + ]); + + const blockMap = editor.internals.doc.blocks.get( + "t1", + ) as TestBlockMapLike; + const tableContent = blockMap.get( + "tableContent", + ) as TestTableContentLike; + const firstRow = tableContent.get(0); + firstRow.get("cells").delete(2, 1); + + let block = editor.getBlock("t1")!; + expect(block.tableColumnCount()).toBe(3); + + editor.apply([ + { + type: "insert-table-row", + blockId: "t1", + index: block.tableRowCount(), + }, + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 2, + offset: 0, + text: "Recovered", + }, + ]); + + block = editor.getBlock("t1")!; + expect(block.tableRowCount()).toBe(3); + expect(block.tableCell(0, 2)?.textContent()).toBe("Recovered"); + expect(block.tableCell(2, 0)).not.toBeNull(); + expect(block.tableCell(2, 1)).not.toBeNull(); + expect(block.tableCell(2, 2)).not.toBeNull(); + + editor.destroy(); + }); + + it("insert-table-column adds a column to all rows", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-column", + blockId: "t1", + index: 2, + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.tableRowCount()).toBe(2); + expect(block.tableColumnCount()).toBe(3); + expect(block.tableCell(0, 2)).not.toBeNull(); + expect(block.tableCell(1, 2)).not.toBeNull(); + + editor.destroy(); + }); + + it("delete-table-row removes a row", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "delete-table-row", + blockId: "t1", + index: 0, + }, + ]); + + expect(editor.getBlock("t1")!.tableRowCount()).toBe(1); + + editor.destroy(); + }); + + it("delete-table-column removes a column from all rows", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "delete-table-column", + blockId: "t1", + index: 0, + }, + ]); + + expect(editor.getBlock("t1")!.tableColumnCount()).toBe(1); + + editor.destroy(); + }); + + it("insert-table-cell-text writes text into a specific cell", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 1, + offset: 0, + text: "Hello", + }, + ]); + + const cell = editor.getBlock("t1")!.tableCell(0, 1)!; + expect(cell.textContent()).toBe("Hello"); + + editor.destroy(); + }); + + it("delete-table-cell-text removes text from a specific cell", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 0, + text: "Hello", + }, + { + type: "delete-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 1, + length: 3, + }, + ]); + + const cell = editor.getBlock("t1")!.tableCell(0, 0)!; + expect(cell.textContent()).toBe("Ho"); + + editor.destroy(); + }); + + it("format-table-cell-text applies formatting to cell text", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + { + type: "insert-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 0, + text: "bold text", + }, + { + type: "format-table-cell-text", + blockId: "t1", + row: 0, + col: 0, + offset: 0, + length: 4, + marks: { bold: true }, + }, + ]); + + const cell = editor.getBlock("t1")!.tableCell(0, 0)!; + const deltas = cell.textDeltas(); + expect(deltas[0].insert).toBe("bold"); + expect(deltas[0].attributes).toEqual({ bold: true }); + expect(deltas[1].insert).toBe(" text"); + + editor.destroy(); + }); + + it("convert-block to table seeds tableContent", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + ]); + + editor.apply([ + { + type: "convert-block", + blockId: "b1", + newType: "table", + newProps: {}, + }, + ]); + + const block = editor.getBlock("b1")!; + expect(block.type).toBe("table"); + expect(block.tableRowCount()).toBe(2); + expect(block.tableColumnCount()).toBe(2); + + editor.destroy(); + }); + +}); diff --git a/packages/core/src/__tests__/editorCore.part8.test.ts b/packages/core/src/__tests__/editorCore.part8.test.ts new file mode 100644 index 0000000..5db979d --- /dev/null +++ b/packages/core/src/__tests__/editorCore.part8.test.ts @@ -0,0 +1,219 @@ +import { yjsAdapter } from "@pen/crdt-yjs"; +import { processStream } from "@pen/delta-stream"; +import { inputRulesExtension } from "@pen/input-rules"; +import { undoExtension } from "@pen/undo"; +import { + defineExtension, + type DocumentSession, + type PenStreamPart, + getOpOriginType, +} from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; + +import { + createDecorationSet, + createDocumentSession, + createEditor as createCoreEditor, + createHeadlessEditor, + ensureInlineCompletionController, +} from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const undoOnlyPreset = { + resolve() { + return { extensions: [undoExtension()] }; + }, +}; + +function createEditor(options: Parameters[0] = {}) { + return createCoreEditor({ + ...options, + preset: options.preset ?? noDefaultExtensionsPreset, + }); +} + +function createDefaultEditor( + options: Parameters[0] = {}, +) { + return createCoreEditor(options); +} + +function createEditorWithUndo( + options: Parameters[0] = {}, +) { + return createCoreEditor({ + ...options, + preset: options.preset ?? undoOnlyPreset, + }); +} + +async function* createStream(parts: PenStreamPart[]) { + for (const part of parts) { + yield part; + } +} + +async function flushMicrotasks(count = 2): Promise { + for (let index = 0; index < count; index++) { + await Promise.resolve(); + } +} + +function visibleText(text: string): string { + return text.replace(/\u200B/g, ""); +} + +type TestYTextLike = { + insert(offset: number, text: string): void; +}; + +type TestBlockMapLike = { + get(key: string): unknown; +}; + +type TestBlocksMapLike = { + get(key: string): TestBlockMapLike | undefined; +}; + +type TestRawDocLike = { + getMap(name: "blocks"): TestBlocksMapLike; +}; + +type TestTableRowLike = { + get(field: "cells"): { delete(index: number, length: number): void }; +}; + +type TestTableContentLike = { + get(index: number): TestTableRowLike; +}; + + +describe("@pen/core table operations", () => { + it("convert-block to table preserves inline text in the first cell", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "b1", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "b1", + offset: 0, + text: "Hello table", + }, + ]); + + editor.apply([ + { + type: "convert-block", + blockId: "b1", + newType: "table", + newProps: {}, + }, + ]); + + const block = editor.getBlock("b1")!; + expect(block.type).toBe("table"); + expect(block.tableCell(0, 0)?.textContent()).toBe("Hello table"); + expect(block.tableCell(0, 1)?.textContent()).toBe(""); + expect(block.tableCell(1, 0)?.textContent()).toBe(""); + expect(block.tableCell(1, 1)?.textContent()).toBe(""); + + editor.destroy(); + }); + + it("tableCell returns null for out-of-bounds coordinates", () => { + const editor = createEditor(); + + editor.apply([ + { + type: "insert-block", + blockId: "t1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + + const block = editor.getBlock("t1")!; + expect(block.tableCell(-1, 0)).toBeNull(); + expect(block.tableCell(0, -1)).toBeNull(); + expect(block.tableCell(99, 0)).toBeNull(); + expect(block.tableCell(0, 99)).toBeNull(); + + editor.destroy(); + }); + + it("tableRowCount/tableColumnCount return 0 for non-table blocks", () => { + const editor = createEditor(); + + const block = editor.firstBlock()!; + expect(block.tableRowCount()).toBe(0); + expect(block.tableColumnCount()).toBe(0); + expect(block.tableCell(0, 0)).toBeNull(); + + editor.destroy(); + }); + + it("caches decoration snapshots between decoration updates", () => { + const editor = createEditor({ + extensions: [ + defineExtension({ + name: "test-decorations", + decorations(_state, currentEditor) { + const blockId = currentEditor.firstBlock()?.id; + if (!blockId) { + return createDecorationSet([]); + } + + return createDecorationSet([ + { + type: "block", + blockId, + attributes: { active: true }, + }, + ]); + }, + }), + ], + }); + + const initialDecorations = editor.getDecorations(); + const repeatedDecorations = editor.getDecorations(); + expect(repeatedDecorations).toBe(initialDecorations); + + editor.apply( + [ + { + type: "insert-text", + blockId: editor.firstBlock()!.id, + offset: 0, + text: "trigger", + }, + ], + { origin: "user" }, + ); + + const autoRefreshedDecorations = editor.getDecorations(); + expect(autoRefreshedDecorations).not.toBe(initialDecorations); + expect(editor.getDecorations()).toBe(autoRefreshedDecorations); + + editor.requestDecorationUpdate(); + + const refreshedDecorations = editor.getDecorations(); + expect(refreshedDecorations).not.toBe(autoRefreshedDecorations); + expect(editor.getDecorations()).toBe(refreshedDecorations); + + editor.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/editorCore.test.ts b/packages/core/src/__tests__/editorCore.test.ts deleted file mode 100644 index 1e7fc7b..0000000 --- a/packages/core/src/__tests__/editorCore.test.ts +++ /dev/null @@ -1,2384 +0,0 @@ -import { yjsAdapter } from "@pen/crdt-yjs"; -import { processStream } from "@pen/delta-stream"; -import { inputRulesExtension } from "@pen/input-rules"; -import { undoExtension } from "@pen/undo"; -import { - defineExtension, - type DocumentSession, - type PenStreamPart, -} from "@pen/types"; -import { describe, expect, it, vi } from "vitest"; - -import { - createDecorationSet, - createDocumentSession, - createEditor as createCoreEditor, -} from "../index"; - -const noDefaultExtensionsPreset = { - resolve() { - return { extensions: [] }; - }, -}; - -const undoOnlyPreset = { - resolve() { - return { extensions: [undoExtension()] }; - }, -}; - -function createEditor( - options: Parameters[0] = {}, -) { - return createCoreEditor({ - ...options, - preset: options.preset ?? noDefaultExtensionsPreset, - }); -} - -function createDefaultEditor( - options: Parameters[0] = {}, -) { - return createCoreEditor(options); -} - -function createEditorWithUndo( - options: Parameters[0] = {}, -) { - return createCoreEditor({ - ...options, - preset: options.preset ?? undoOnlyPreset, - }); -} - -async function* createStream(parts: PenStreamPart[]) { - for (const part of parts) { - yield part; - } -} - -async function flushMicrotasks(count = 2): Promise { - for (let index = 0; index < count; index++) { - await Promise.resolve(); - } -} - -function visibleText(text: string): string { - return text.replace(/\u200B/g, ""); -} - -type TestYTextLike = { - insert(offset: number, text: string): void; -}; - -type TestBlockMapLike = { - get(key: string): unknown; -}; - -type TestBlocksMapLike = { - get(key: string): TestBlockMapLike | undefined; -}; - -type TestRawDocLike = { - getMap(name: "blocks"): TestBlocksMapLike; -}; - -type TestTableRowLike = { - get(field: "cells"): { delete(index: number, length: number): void }; -}; - -type TestTableContentLike = { - get(index: number): TestTableRowLike; -}; - -describe("@pen/core createEditor", () => { - it("warns once when using the deprecated without option", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const editor = createCoreEditor({ - without: ["document-ops"], - }); - editor.destroy(); - - expect(warnSpy).toHaveBeenCalledWith( - "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", - ); - - warnSpy.mockRestore(); - }); - - it("installs extensions from presets before user extensions", () => { - const editor = createEditor({ - preset: { - resolve() { - return { - extensions: [ - defineExtension({ - name: "preset-test-extension", - activateClient: async (ctx) => { - ctx.editor.internals.setSlot("test:preset-installed", true); - }, - }), - ], - }; - }, - }, - }); - - expect(editor.internals.getSlot("test:preset-installed")).toBe(true); - - editor.destroy(); - }); - - it("supports multiple editors sharing one document session", () => { - const session = createDocumentSession({ - adapter: yjsAdapter(), - }); - const editorA = createEditor({ - documentSession: session, - }); - const editorB = createEditor({ - documentSession: session, - }); - const blockId = editorA.firstBlock()!.id; - - editorA.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Shared", - }, - ]); - - expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared"); - expect(editorA.documentScope.id).toBe(editorB.documentScope.id); - expect(editorA.internals.documentSession).toBe(session); - expect(editorB.internals.documentSession).toBe(session); - - editorA.destroy(); - editorB.apply([ - { - type: "insert-text", - blockId, - offset: 6, - text: " doc", - }, - ]); - - expect(editorB.getBlock(blockId)?.textContent()).toBe("Shared doc"); - - editorB.destroy(); - session.destroy(); - }); - - it("does not destroy caller-owned documents on editor teardown", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - const editorA = createEditor({ - document, - }); - const blockId = editorA.firstBlock()!.id; - - editorA.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Persisted", - }, - ]); - editorA.destroy(); - - const editorB = createEditor({ - document, - }); - - expect(editorB.getBlock(blockId)?.textContent()).toBe("Persisted"); - - editorB.destroy(); - }); - - it("persists document profile metadata for new editors", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - - expect(editor.documentProfile).toBe("flow"); - expect(editor.documentState.documentProfile).toBe("flow"); - expect(editor.editorViewMode).toBe("flow"); - expect( - editor.internals.adapter.getDocumentProfile?.(editor.internals.crdtDoc), - ).toBe("flow"); - - editor.destroy(); - }); - - it("loads persisted document profile independently from local editor view mode", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - adapter.setDocumentProfile?.(document, "flow"); - - const editor = createEditor({ - document, - editorViewMode: "structured", - }); - - expect(editor.documentProfile).toBe("flow"); - expect(editor.documentState.documentProfile).toBe("flow"); - expect(editor.editorViewMode).toBe("structured"); - - editor.destroy(); - }); - - it("keeps document profile in sync with persisted metadata changes", () => { - const adapter = yjsAdapter(); - const document = adapter.createDocument(); - const editor = createEditor({ - document, - }); - - expect(editor.documentProfile).toBe("structured"); - expect(editor.documentState.documentProfile).toBe("structured"); - - adapter.setDocumentProfile?.(document, "flow"); - - expect(editor.documentProfile).toBe("flow"); - expect(editor.documentState.documentProfile).toBe("flow"); - expect(editor.editorViewMode).toBe("flow"); - - editor.destroy(); - }); - - it("drops flow-disallowed block insertions at the mutation boundary", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "db1", - blockType: "database", - props: {}, - position: "last", - }, - ]); - - expect(editor.getBlock("db1")).toBeNull(); - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_PROFILE_001", - level: "warn", - source: "profile-policy", - blockType: "database", - documentProfile: "flow", - }), - ); - - editor.destroy(); - }); - - it("re-applies the flow mutation boundary after extension hooks run", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.onBeforeApply( - (ops) => [ - ...ops, - { - type: "insert-block", - blockId: "db-after-hook", - blockType: "database", - props: {}, - position: "last", - }, - ], - { priority: 20000 }, - ); - - editor.apply([ - { - type: "insert-block", - blockId: "p-after-hook", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - expect(editor.getBlock("p-after-hook")?.type).toBe("paragraph"); - expect(editor.getBlock("db-after-hook")).toBeNull(); - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_PROFILE_001", - blockType: "database", - documentProfile: "flow", - }), - ); - - editor.destroy(); - }); - - it("drops flow-disallowed block conversions at the mutation boundary", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - const firstBlockId = editor.firstBlock()!.id; - - editor.apply([ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "Hello", - }, - ]); - - editor.apply([ - { - type: "convert-block", - blockId: firstBlockId, - newType: "database", - newProps: {}, - }, - ]); - - expect(editor.getBlock(firstBlockId)?.type).toBe("paragraph"); - expect(editor.getBlock(firstBlockId)?.textContent()).toBe("Hello"); - - editor.destroy(); - }); - - it("still allows optional structural blocks in flow documents", () => { - const editor = createEditor({ - documentProfile: "flow", - }); - - editor.apply([ - { - type: "insert-block", - blockId: "table1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - expect(editor.getBlock("table1")?.type).toBe("table"); - - editor.destroy(); - }); - - it("discovers subdocument scopes and lets nested editors edit them", () => { - const session = createDocumentSession({ - adapter: yjsAdapter(), - }); - const rootEditor = createEditor({ - documentSession: session, - }); - - rootEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested" }, - position: "last", - }, - ]); - - const childScope = session.getScopeForBlock("subdoc-block", { - scopeId: rootEditor.documentScope.id, - }); - expect(childScope).not.toBeNull(); - expect(rootEditor.getBlock("subdoc-block")?.props.subdocumentGuid).toBe( - childScope?.guid, - ); - - const childEditor = createEditor({ - documentSession: session, - documentScopeId: childScope!.id, - }); - const childBlockId = childEditor.firstBlock()!.id; - - childEditor.apply([ - { - type: "insert-text", - blockId: childBlockId, - offset: 0, - text: "Nested content", - }, - ]); - - expect(childEditor.getBlock(childBlockId)?.textContent()).toBe( - "Nested content", - ); - expect(childEditor.documentScope.parentId).toBe(rootEditor.documentScope.id); - expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); - - childEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested Nested" }, - position: "last", - }, - ]); - - const nestedScope = session.getScopeForBlock("subdoc-block", { - scopeId: childEditor.documentScope.id, - }); - expect(nestedScope).not.toBeNull(); - expect(nestedScope?.id).not.toBe(childScope?.id); - expect(session.getScopeForBlock("subdoc-block")).toBeNull(); - - childEditor.destroy(); - rootEditor.destroy(); - session.destroy(); - }); - - it("supports delegated document session implementations for scope replacement", async () => { - const baseSession = createDocumentSession({ - adapter: yjsAdapter(), - }); - const delegatedSession: DocumentSession = { - adapter: baseSession.adapter, - get rootScope() { - return baseSession.rootScope; - }, - getScope: (scopeId) => baseSession.getScope(scopeId), - getScopeByGuid: (guid) => baseSession.getScopeByGuid(guid), - getScopeForBlock: (blockId, options) => - baseSession.getScopeForBlock(blockId, options), - listScopes: () => baseSession.listScopes(), - getAwareness: (scopeId) => baseSession.getAwareness(scopeId), - observe: (scopeId, callback) => baseSession.observe(scopeId, callback), - observeAll: (callback) => baseSession.observeAll(callback), - createSubdocument: (blockId, options) => - baseSession.createSubdocument(blockId, options), - loadSubdocument: (scopeId) => baseSession.loadSubdocument(scopeId), - replaceScopeDocument: (scopeId, doc, options) => - baseSession.replaceScopeDocument(scopeId, doc, options), - attachEditor: (options) => baseSession.attachEditor(options), - destroy: () => baseSession.destroy(), - }; - const editor = createEditor({ - documentSession: delegatedSession, - }); - const originalDoc = editor.internals.crdtDoc; - const replacementSource = createEditor(); - const replacementDoc = delegatedSession.adapter.loadDocument( - delegatedSession.adapter.encodeState(replacementSource.internals.crdtDoc), - ); - - delegatedSession.replaceScopeDocument(editor.documentScope.id, replacementDoc); - await flushMicrotasks(); - - expect(editor.internals.crdtDoc).toBe(replacementDoc); - expect(editor.internals.crdtDoc).not.toBe(originalDoc); - expect(editor.firstBlock()).not.toBeNull(); - - replacementSource.destroy(); - editor.destroy(); - delegatedSession.destroy(); - }); - - it("rebinds child-scope editors when the root session document is replaced", async () => { - const session = createDocumentSession({ - adapter: yjsAdapter(), - }); - const rootEditor = createEditor({ - documentSession: session, - }); - rootEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested" }, - position: "last", - }, - ]); - const childScope = session.getScopeForBlock("subdoc-block", { - scopeId: rootEditor.documentScope.id, - }); - const childEditor = createEditor({ - documentSession: session, - documentScopeId: childScope!.id, - }); - const childBlockId = childEditor.firstBlock()!.id; - childEditor.apply([ - { - type: "insert-text", - blockId: childBlockId, - offset: 0, - text: "Original nested content", - }, - ]); - - const replacementSession = createDocumentSession({ - adapter: yjsAdapter(), - ownsDocuments: false, - }); - const replacementRootEditor = createEditor({ - documentSession: replacementSession, - }); - replacementRootEditor.apply([ - { - type: "insert-block", - blockId: "subdoc-block", - blockType: "subdocument", - props: { title: "Nested" }, - position: "last", - }, - ]); - const replacementChildScope = replacementSession.getScopeForBlock( - "subdoc-block", - { - scopeId: replacementRootEditor.documentScope.id, - }, - ); - const replacementChildEditor = createEditor({ - documentSession: replacementSession, - documentScopeId: replacementChildScope!.id, - }); - const replacementChildBlockId = replacementChildEditor.firstBlock()!.id; - replacementChildEditor.apply([ - { - type: "insert-text", - blockId: replacementChildBlockId, - offset: 0, - text: "Replacement nested content", - }, - ]); - - session.replaceScopeDocument(rootEditor.documentScope.id, replacementSession.rootScope.doc); - await flushMicrotasks(); - - expect(childEditor.firstBlock()?.textContent()).toBe( - "Replacement nested content", - ); - expect(childEditor.documentScope.ownerBlockId).toBe("subdoc-block"); - expect(childEditor.documentScope.parentId).toBe(rootEditor.documentScope.id); - - replacementChildEditor.destroy(); - replacementRootEditor.destroy(); - replacementSession.destroy(); - childEditor.destroy(); - rootEditor.destroy(); - session.destroy(); - }); - - it("creates a working editor with default schema and extensions", () => { - const editor = createDefaultEditor(); - - expect(editor.schema.resolve("paragraph")).toBeTruthy(); - expect(typeof editor.clientId).toBe("number"); - expect(editor.internals.getSlot("core:engine")).toBe( - editor.internals.engine, - ); - expect( - editor.internals.getSlot("document-ops:toolRuntime"), - ).toBeTruthy(); - expect(editor.internals.getSlot("undo:manager")).toBeTruthy(); - - editor.destroy(); - }); - - it("starts with a single empty paragraph block in zero-config mode", () => { - const editor = createDefaultEditor(); - - expect(editor.blockCount()).toBe(1); - expect(editor.firstBlock()?.type).toBe("paragraph"); - expect(editor.firstBlock()?.textContent()).toBe(""); - - editor.destroy(); - }); - - it("applies insert-block and insert-text operations", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello"); - - editor.destroy(); - }); - - it("splits and merges inline blocks", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello world", - }, - ]); - - editor.apply([ - { - type: "split-block", - blockId: "b1", - offset: 5, - newBlockId: "b2", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello"); - expect(editor.getBlock("b2")?.textContent()).toBe(" world"); - - editor.apply([ - { - type: "merge-blocks", - targetBlockId: "b1", - sourceBlockId: "b2", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello world"); - expect(editor.getBlock("b2")).toBeNull(); - - editor.destroy(); - }); - - it("preserves full text offsets for code blocks", () => { - const editor = createEditor(); - const blockId = editor.firstBlock()!.id; - - editor.apply([ - { type: "convert-block", blockId, newType: "codeBlock" }, - { type: "insert-text", blockId, offset: 0, text: "abcd" }, - ]); - - editor.selectTextRange( - { blockId, offset: 1 }, - { blockId, offset: 3 }, - ); - - expect(editor.selection).toMatchObject({ - type: "text", - anchor: { blockId, offset: 1 }, - focus: { blockId, offset: 3 }, - }); - expect(editor.getSelectedText()).toBe("bc"); - - editor.destroy(); - }); - - it("clears stale grid state when converting table or database blocks", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "table-block", - blockType: "table", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "database-block", - blockType: "database", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "database-insert-row", - blockId: "database-block", - rowId: "row-1", - values: { - name: "Alpha", - tags: "todo", - status: "true", - }, - }, - { - type: "convert-block", - blockId: "table-block", - newType: "paragraph", - }, - { - type: "convert-block", - blockId: "database-block", - newType: "paragraph", - }, - ]); - - const tableBlock = editor.getBlock("table-block")!; - const databaseBlock = editor.getBlock("database-block")!; - expect(tableBlock.type).toBe("paragraph"); - expect(tableBlock.tableRowCount()).toBe(0); - expect(tableBlock.tableColumns()).toEqual([]); - expect(tableBlock.databaseViews()).toEqual([]); - - expect(databaseBlock.type).toBe("paragraph"); - expect(databaseBlock.tableRowCount()).toBe(0); - expect(databaseBlock.tableColumns()).toEqual([]); - expect(databaseBlock.databaseViews()).toEqual([]); - expect(databaseBlock.databasePrimaryViewId()).toBeNull(); - - const tableBlockMap = editor.internals.doc.blocks.get( - "table-block", - ) as TestBlockMapLike; - const databaseBlockMap = editor.internals.doc.blocks.get( - "database-block", - ) as TestBlockMapLike; - expect(tableBlockMap.get("tableContent")).toBeUndefined(); - expect(tableBlockMap.get("tableColumns")).toBeUndefined(); - expect(tableBlockMap.get("databaseViews")).toBeUndefined(); - expect(tableBlockMap.get("databasePrimaryViewId")).toBeUndefined(); - expect(databaseBlockMap.get("tableContent")).toBeUndefined(); - expect(databaseBlockMap.get("tableColumns")).toBeUndefined(); - expect(databaseBlockMap.get("databaseViews")).toBeUndefined(); - expect(databaseBlockMap.get("databasePrimaryViewId")).toBeUndefined(); - - editor.destroy(); - }); - - it("queues reentrant apply calls from observe hooks", () => { - let appended = false; - const ext = defineExtension({ - name: "append-exclamation", - observe(events, editor) { - if (appended) return; - const hasInsertText = events.some((event) => - event.ops.some((op) => op.type === "insert-text"), - ); - if (!hasInsertText) return; - - appended = true; - editor.apply( - [ - { - type: "insert-text", - blockId: "b1", - offset: 5, - text: "!", - }, - ], - { origin: "extension" }, - ); - }, - }); - - const editor = createEditor({ - extensions: [ext], - }); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello", - }, - ]); - - expect(editor.getBlock("b1")?.textContent()).toBe("hello!"); - - editor.destroy(); - }); - - it("activates input-rules extensions and applies block conversions", async () => { - const editor = createEditor({ - extensions: [inputRulesExtension()], - }); - const blockId = editor.firstBlock()!.id; - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 0 }, - ); - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 0, - text: "#", - }, - ], - { origin: "user" }, - ); - editor.selectTextRange( - { blockId, offset: 1 }, - { blockId, offset: 1 }, - ); - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 1, - text: " ", - }, - ], - { origin: "user" }, - ); - await flushMicrotasks(); - - expect(editor.getBlock(blockId)?.type).toBe("heading"); - expect(editor.getBlock(blockId)?.props.level).toBe(1); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); - - editor.destroy(); - }); - - it("activates input-rules extensions and applies inline markdown conversions", async () => { - const editor = createEditor({ - extensions: [inputRulesExtension()], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 0, - text: "**hello*", - }, - ], - { origin: "user" }, - ); - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 8, - text: "*", - }, - ], - { origin: "user" }, - ); - await flushMicrotasks(); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe("hello"); - expect(editor.getBlock(blockId)?.textDeltas()).toEqual([ - { - insert: "hello", - attributes: { bold: true }, - }, - ]); - - editor.destroy(); - }); - - it("emits unified change and documentCommit once for a local apply batch", () => { - const observed: unknown[][] = []; - const ext = defineExtension({ - name: "capture-local-dispatch", - observe(events) { - observed.push(events); - }, - }); - const editor = createEditor({ - extensions: [ext], - }); - const changes: unknown[][] = []; - const documentCommits: unknown[] = []; - const blockId = editor.firstBlock()!.id; - - editor.on("change", (events) => { - changes.push(events); - }); - editor.on("documentCommit", (event) => { - documentCommits.push(event); - }); - observed.length = 0; - changes.length = 0; - documentCommits.length = 0; - - editor.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "hello", - }, - ]); - - expect(changes).toHaveLength(1); - expect(changes[0]).toHaveLength(1); - expect(changes[0][0]).toMatchObject({ - origin: "user", - affectedBlocks: [blockId], - }); - expect(documentCommits).toHaveLength(1); - expect(documentCommits[0]).toMatchObject({ - commitId: 2, - origin: "user", - affectedBlocks: [blockId], - }); - expect((documentCommits[0] as { blockRevisions: Record }).blockRevisions[blockId]).toBe( - editor.getBlockRevision(blockId), - ); - expect(observed).toHaveLength(1); - expect(observed[0]).toHaveLength(1); - - editor.destroy(); - }); - - it("emits unified change and documentCommit once for observed CRDT updates", () => { - const observed: unknown[][] = []; - const ext = defineExtension({ - name: "capture-observed-dispatch", - observe(events) { - observed.push(events); - }, - }); - const editor = createEditor({ - extensions: [ext], - }); - const changes: unknown[][] = []; - const documentCommits: unknown[] = []; - const adapter = editor.internals.adapter; - const editorDoc = editor.internals.crdtDoc; - const blockId = editor.firstBlock()!.id; - const remoteDoc = adapter.loadDocument(adapter.encodeState(editorDoc)); - const remoteYDoc = adapter.raw(remoteDoc); - const remoteYText = remoteYDoc - .getMap("blocks") - .get(blockId) - ?.get("content") as TestYTextLike | undefined; - if (!remoteYText) { - throw new Error(`Missing collaborator text for block ${blockId}`); - } - - editor.on("change", (events) => { - changes.push(events); - }); - editor.on("documentCommit", (event) => { - documentCommits.push(event); - }); - observed.length = 0; - changes.length = 0; - documentCommits.length = 0; - - adapter.transact( - remoteDoc, - () => { - remoteYText.insert(0, "remote "); - }, - "collaborator", - ); - adapter.applyUpdate(editorDoc, adapter.encodeState(remoteDoc)); - - expect(changes).toHaveLength(1); - expect(changes[0]).toHaveLength(1); - expect(changes[0][0]).toMatchObject({ - affectedBlocks: [blockId], - }); - expect(documentCommits).toHaveLength(1); - expect(documentCommits[0]).toMatchObject({ - commitId: 2, - affectedBlocks: [blockId], - }); - expect((documentCommits[0] as { blockRevisions: Record }).blockRevisions[blockId]).toBe( - editor.getBlockRevision(blockId), - ); - expect(observed).toHaveLength(1); - expect(observed[0]).toHaveLength(1); - - editor.destroy(); - }); - - it("clamps text selections and returns backwards selected text", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "hello", - }, - ]); - - editor.selectText("b1", 10, 99); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 5 }, - focus: { blockId: "b1", offset: 5 }, - }); - - editor.setSelection({ - type: "text", - anchor: { blockId: "b1", offset: 5 }, - focus: { blockId: "b1", offset: 2 }, - isCollapsed: false, - isMultiBlock: false, - blockRange: ["b1"], - toRange: () => { - throw new Error("test helper"); - }, - }); - - expect(editor.getSelectedText()).toBe("llo"); - - editor.destroy(); - }); - - it("selects text ranges across blocks in document order", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 3 }, - ); - - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 2 }, - focus: { blockId: "b3", offset: 3 }, - isMultiBlock: true, - blockRange: ["b1", "b2", "b3"], - }); - expect(editor.getSelectedText()).toBe("llo\nWorld\nAga"); - expect(editor.getSelectedBlocks().map((block) => block.id)).toEqual([ - "b1", - "b2", - "b3", - ]); - - editor.destroy(); - }); - - it("deletes multi-block text selections and collapses at the start", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("b1")?.textContent()).toBe("Heain"); - expect(editor.getBlock("b2")).toBeNull(); - expect(editor.getBlock("b3")).toBeNull(); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 2 }, - focus: { blockId: "b1", offset: 2 }, - isMultiBlock: false, - blockRange: ["b1"], - }); - - editor.destroy(); - }); - - it("deletes a fully selected structural block", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "d1", - blockType: "divider", - props: {}, - position: "last", - }, - ]); - - editor.selectTextRange( - { blockId: "d1", offset: 0 }, - { blockId: "d1", offset: 1 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("d1")).toBeNull(); - expect(editor.getSelection()).toBeNull(); - - editor.destroy(); - }); - - it("deletes a fully selected delegated block", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.selectTextRange( - { blockId: "t1", offset: 0 }, - { blockId: "t1", offset: 1 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("t1")).toBeNull(); - expect(editor.getSelection()).toBeNull(); - - editor.destroy(); - }); - - it("deletes structural blocks at multi-block selection boundaries", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "p1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "d1", - blockType: "divider", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "p1", offset: 0, text: "Hello" }, - ]); - - editor.selectTextRange( - { blockId: "p1", offset: 2 }, - { blockId: "d1", offset: 1 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("p1")?.textContent()).toBe("He"); - expect(editor.getBlock("d1")).toBeNull(); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "p1", offset: 2 }, - focus: { blockId: "p1", offset: 2 }, - isMultiBlock: false, - blockRange: ["p1"], - }); - - editor.destroy(); - }); - - it("replaces multi-block text selections at a single insertion point", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - editor.replaceSelection("X"); - - expect(editor.getBlock("b1")?.textContent()).toBe("HeXain"); - expect(editor.getBlock("b2")).toBeNull(); - expect(editor.getBlock("b3")).toBeNull(); - expect(editor.getSelection()).toMatchObject({ - type: "text", - anchor: { blockId: "b1", offset: 3 }, - focus: { blockId: "b1", offset: 3 }, - isMultiBlock: false, - blockRange: ["b1"], - }); - - editor.destroy(); - }); - - it("preserves formatted suffix text when deleting across blocks", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "Again" }, - { - type: "format-text", - blockId: "b2", - offset: 2, - length: 3, - marks: { bold: true }, - }, - ]); - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b2", offset: 2 }, - ); - editor.deleteSelection(); - - expect(editor.getBlock("b1")?.textDeltas()).toEqual([ - { insert: "He" }, - { - insert: "ain", - attributes: { bold: true }, - }, - ]); - expect(editor.getBlock("b2")).toBeNull(); - - editor.destroy(); - }); - - it("replaces multi-block text selections in a single document commit batch", () => { - const editor = createEditor(); - const events: Array<{ ops: readonly { type: string }[] }> = []; - - editor.on("documentCommit", (event) => { - events.push(event as { ops: readonly { type: string }[] }); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: "b1", offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - events.length = 0; - - editor.selectTextRange( - { blockId: "b1", offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - editor.replaceSelection("X"); - - expect(events).toHaveLength(1); - expect(events[0]?.ops.map((op) => op.type)).toEqual([ - "delete-text", - "delete-text", - "delete-block", - "insert-text", - "insert-text", - "delete-block", - ]); - - editor.destroy(); - }); - -it("rebinds undo manager after loadDocument", async () => { - const editor = createDefaultEditor(); - const newDoc = editor.internals.adapter.createDocument(); - - editor.loadDocument(newDoc); - await flushMicrotasks(); - - expect(editor.undoManager).toBe( - editor.internals.getSlot("undo:manager"), - ); - - editor.destroy(); - }); - - it("waits for async extension teardown before reactivating after loadDocument", async () => { - const steps: string[] = []; - let activationCount = 0; - let resolveDeactivate!: () => void; - const deactivatePromise = new Promise((resolve) => { - resolveDeactivate = resolve; - }); - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "async-lifecycle", - activateClient: async () => { - activationCount += 1; - steps.push(`activate:${activationCount}`); - }, - deactivateClient: async () => { - steps.push("deactivate:start"); - await deactivatePromise; - steps.push("deactivate:end"); - }, - }), - ], - }); - - await flushMicrotasks(); - - editor.loadDocument(editor.internals.adapter.createDocument()); - await flushMicrotasks(); - - expect(steps).toEqual(["activate:1", "deactivate:start"]); - - resolveDeactivate(); - await flushMicrotasks(4); - - expect(steps).toEqual([ - "activate:1", - "deactivate:start", - "deactivate:end", - "activate:2", - ]); - - editor.destroy(); - }); - - it("refreshes editor.undoManager immediately when the undo slot is set", async () => { - const registeredUndoManager = { - undo: () => false, - redo: () => false, - canUndo: () => false, - canRedo: () => false, - stopCapturing: () => { }, - syncExplicitUndoGroup: () => { }, - setGroupTimeout: () => { }, - registerTrackedOrigins: () => () => { }, - onStackChange: () => () => { }, - }; - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "test-undo-slot", - activateClient: async ({ editor }) => { - expect(editor.undoManager).not.toBe(registeredUndoManager); - editor.internals.setSlot("undo:manager", registeredUndoManager); - expect(editor.undoManager).toBe(registeredUndoManager); - }, - }), - ], - }); - - await Promise.resolve(); - - expect(editor.undoManager).toBe(registeredUndoManager); - - editor.destroy(); - }); - - it("updates documentState parent relationships after parentId changes", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "parent", - blockType: "toggle", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "child", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "update-block", - blockId: "child", - props: { parentId: "parent" }, - }, - ]); - - expect(editor.documentState.parentOf("child")).toBe("parent"); - - editor.apply([ - { - type: "update-block", - blockId: "child", - props: { parentId: null }, - }, - ]); - - expect(editor.documentState.parentOf("child")).toBeNull(); - - editor.destroy(); - }); - - it("emits structured diagnostics for unknown block types", () => { - const editor = createEditor(); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "unknown", - blockType: "not-real", - props: {}, - position: "last", - }, - ]); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - }), - ); - - editor.destroy(); - }); - - it("emits remediation text for extension observe failures", () => { - const diagnostics: unknown[] = []; - const ext = defineExtension({ - name: "broken-observe", - observe() { - throw new Error("boom"); - }, - }); - const editor = createEditor({ - extensions: [ext], - }); - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_EXT_001", - level: "error", - source: "extension", - remediation: expect.any(String), - }), - ); - - editor.destroy(); - }); - - it("emits diagnostics for rejected async extension activation", async () => { - const diagnostics: unknown[] = []; - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "broken-async-activate", - activateClient: async () => { - await Promise.resolve(); - throw new Error("boom"); - }, - }), - ], - }); - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - await flushMicrotasks(4); - - expect(diagnostics).toContainEqual( - expect.objectContaining({ - code: "PEN_EXT_004", - level: "error", - source: "extension", - extension: "broken-async-activate", - remediation: expect.any(String), - }), - ); - - editor.destroy(); - }); - - it("processes streamed AI deltas through the default delta-stream pipeline", async () => { - const editor = createDefaultEditor(); - const blockId = editor.firstBlock()!.id; - - await processStream( - createStream([ - { type: "gen-start", zoneId: "zone-1", blockId }, - { type: "gen-delta", zoneId: "zone-1", delta: "Hello " }, - { type: "gen-delta", zoneId: "zone-1", delta: "world" }, - { type: "gen-end", zoneId: "zone-1", status: "complete" }, - ]), - editor, - ); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "Hello world", - ); - expect( - editor.internals.getSlot<{ generationZone: unknown }>( - "delta-stream:target", - )?.generationZone ?? null, - ).toBeNull(); - - editor.destroy(); - }); - - it("keeps streamed AI generations in their own undo group", async () => { - const editor = createDefaultEditor(); - const firstBlockId = editor.firstBlock()!.id; - const secondBlockId = crypto.randomUUID(); - - editor.apply( - [ - { - type: "insert-block", - blockId: secondBlockId, - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - - editor.apply( - [ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "hello", - }, - ], - { origin: "user" }, - ); - - await processStream( - createStream([ - { type: "gen-start", zoneId: "zone-2", blockId: secondBlockId }, - { type: "gen-delta", zoneId: "zone-2", delta: "AI output" }, - { type: "gen-end", zoneId: "zone-2", status: "complete" }, - ]), - editor, - ); - - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "hello", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "hello", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - expect(editor.undoManager.redo()).toBe(true); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - editor.destroy(); - }); - - it("keeps concurrent user edits outside the generation zone in a separate undo group", async () => { - const editor = createDefaultEditor(); - const firstBlockId = editor.firstBlock()!.id; - const secondBlockId = crypto.randomUUID(); - - editor.apply( - [ - { - type: "insert-block", - blockId: secondBlockId, - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - - await processStream( - (async function* (): AsyncIterable { - yield { - type: "gen-start", - zoneId: "zone-concurrent", - blockId: secondBlockId, - }; - - editor.apply( - [ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "user edit", - }, - ], - { origin: "user" }, - ); - - yield { - type: "gen-delta", - zoneId: "zone-concurrent", - delta: "AI output", - }; - yield { - type: "gen-end", - zoneId: "zone-concurrent", - status: "complete", - }; - })(), - editor, - ); - - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "user edit", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe( - "user edit", - ); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "", - ); - - expect(editor.undoManager.redo()).toBe(true); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe( - "AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(firstBlockId)!.textContent())).toBe(""); - expect(visibleText(editor.getBlock(secondBlockId)!.textContent())).toBe(""); - - editor.destroy(); - }); - - it("keeps user edits inside the generation zone in the same undo group", async () => { - const editor = createDefaultEditor(); - const blockId = editor.firstBlock()!.id; - - await processStream( - (async function* (): AsyncIterable { - yield { type: "gen-start", zoneId: "zone-shared", blockId }; - yield { type: "gen-delta", zoneId: "zone-shared", delta: "AI " }; - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 3, - text: "user ", - }, - ], - { origin: "user" }, - ); - - yield { - type: "gen-delta", - zoneId: "zone-shared", - delta: "output", - }; - yield { - type: "gen-end", - zoneId: "zone-shared", - status: "complete", - }; - })(), - editor, - ); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "user AI output", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); - - expect(editor.undoManager.redo()).toBe(true); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "user AI output", - ); - - editor.destroy(); - }); - - it("tracks imported edits in the undo stack", () => { - const editor = createEditorWithUndo(); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [ - { - type: "insert-text", - blockId, - offset: 0, - text: "Imported text", - }, - ], - { origin: "import", undoGroup: true }, - ); - - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe( - "Imported text", - ); - expect(editor.undoManager.undo()).toBe(true); - expect(visibleText(editor.getBlock(blockId)!.textContent())).toBe(""); - - editor.destroy(); - }); - - it("emits history origin for undo transactions on documentCommit", () => { - const editor = createEditorWithUndo(); - const blockId = editor.firstBlock()!.id; - const commitOrigins: string[] = []; - - editor.on("documentCommit", (event) => { - commitOrigins.push(event.origin); - }); - - editor.apply([ - { - type: "insert-text", - blockId, - offset: 0, - text: "Hello", - }, - ]); - - editor.undoManager.undo(); - - expect(commitOrigins).toContain("user"); - expect(commitOrigins).toContain("history"); - - editor.destroy(); - }); -}); - -describe("@pen/core table operations", () => { - it("insert-block with table type produces seeded 2x2 grid", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.type).toBe("table"); - expect(block.tableRowCount()).toBe(2); - expect(block.tableColumnCount()).toBe(2); - - const cell = block.tableCell(0, 0)!; - expect(cell).not.toBeNull(); - expect(cell.id).toEqual(expect.any(String)); - expect(cell.textContent()).toBe(""); - - editor.destroy(); - }); - - it("insert-table-row adds a row matching existing column count", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-row", - blockId: "t1", - index: 2, - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.tableRowCount()).toBe(3); - expect(block.tableColumnCount()).toBe(2); - expect(block.tableCell(2, 0)).not.toBeNull(); - expect(block.tableCell(2, 1)).not.toBeNull(); - - editor.destroy(); - }); - - it("repairs table width from the widest row when legacy rows are short", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-column", - blockId: "t1", - index: 2, - }, - ]); - - const blockMap = editor.internals.doc.blocks.get("t1") as TestBlockMapLike; - const tableContent = blockMap.get("tableContent") as TestTableContentLike; - const firstRow = tableContent.get(0); - firstRow.get("cells").delete(2, 1); - - let block = editor.getBlock("t1")!; - expect(block.tableColumnCount()).toBe(3); - - editor.apply([ - { - type: "insert-table-row", - blockId: "t1", - index: block.tableRowCount(), - }, - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 2, - offset: 0, - text: "Recovered", - }, - ]); - - block = editor.getBlock("t1")!; - expect(block.tableRowCount()).toBe(3); - expect(block.tableCell(0, 2)?.textContent()).toBe("Recovered"); - expect(block.tableCell(2, 0)).not.toBeNull(); - expect(block.tableCell(2, 1)).not.toBeNull(); - expect(block.tableCell(2, 2)).not.toBeNull(); - - editor.destroy(); - }); - - it("insert-table-column adds a column to all rows", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-column", - blockId: "t1", - index: 2, - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.tableRowCount()).toBe(2); - expect(block.tableColumnCount()).toBe(3); - expect(block.tableCell(0, 2)).not.toBeNull(); - expect(block.tableCell(1, 2)).not.toBeNull(); - - editor.destroy(); - }); - - it("delete-table-row removes a row", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "delete-table-row", - blockId: "t1", - index: 0, - }, - ]); - - expect(editor.getBlock("t1")!.tableRowCount()).toBe(1); - - editor.destroy(); - }); - - it("delete-table-column removes a column from all rows", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "delete-table-column", - blockId: "t1", - index: 0, - }, - ]); - - expect(editor.getBlock("t1")!.tableColumnCount()).toBe(1); - - editor.destroy(); - }); - - it("insert-table-cell-text writes text into a specific cell", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 1, - offset: 0, - text: "Hello", - }, - ]); - - const cell = editor.getBlock("t1")!.tableCell(0, 1)!; - expect(cell.textContent()).toBe("Hello"); - - editor.destroy(); - }); - - it("delete-table-cell-text removes text from a specific cell", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 0, - text: "Hello", - }, - { - type: "delete-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 1, - length: 3, - }, - ]); - - const cell = editor.getBlock("t1")!.tableCell(0, 0)!; - expect(cell.textContent()).toBe("Ho"); - - editor.destroy(); - }); - - it("format-table-cell-text applies formatting to cell text", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - { - type: "insert-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 0, - text: "bold text", - }, - { - type: "format-table-cell-text", - blockId: "t1", - row: 0, - col: 0, - offset: 0, - length: 4, - marks: { bold: true }, - }, - ]); - - const cell = editor.getBlock("t1")!.tableCell(0, 0)!; - const deltas = cell.textDeltas(); - expect(deltas[0].insert).toBe("bold"); - expect(deltas[0].attributes).toEqual({ bold: true }); - expect(deltas[1].insert).toBe(" text"); - - editor.destroy(); - }); - - it("convert-block to table seeds tableContent", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - ]); - - editor.apply([ - { - type: "convert-block", - blockId: "b1", - newType: "table", - newProps: {}, - }, - ]); - - const block = editor.getBlock("b1")!; - expect(block.type).toBe("table"); - expect(block.tableRowCount()).toBe(2); - expect(block.tableColumnCount()).toBe(2); - - editor.destroy(); - }); - - it("convert-block to table preserves inline text in the first cell", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "b1", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "b1", - offset: 0, - text: "Hello table", - }, - ]); - - editor.apply([ - { - type: "convert-block", - blockId: "b1", - newType: "table", - newProps: {}, - }, - ]); - - const block = editor.getBlock("b1")!; - expect(block.type).toBe("table"); - expect(block.tableCell(0, 0)?.textContent()).toBe("Hello table"); - expect(block.tableCell(0, 1)?.textContent()).toBe(""); - expect(block.tableCell(1, 0)?.textContent()).toBe(""); - expect(block.tableCell(1, 1)?.textContent()).toBe(""); - - editor.destroy(); - }); - - it("tableCell returns null for out-of-bounds coordinates", () => { - const editor = createEditor(); - - editor.apply([ - { - type: "insert-block", - blockId: "t1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - - const block = editor.getBlock("t1")!; - expect(block.tableCell(-1, 0)).toBeNull(); - expect(block.tableCell(0, -1)).toBeNull(); - expect(block.tableCell(99, 0)).toBeNull(); - expect(block.tableCell(0, 99)).toBeNull(); - - editor.destroy(); - }); - - it("tableRowCount/tableColumnCount return 0 for non-table blocks", () => { - const editor = createEditor(); - - const block = editor.firstBlock()!; - expect(block.tableRowCount()).toBe(0); - expect(block.tableColumnCount()).toBe(0); - expect(block.tableCell(0, 0)).toBeNull(); - - editor.destroy(); - }); - - it("caches decoration snapshots between decoration updates", () => { - const editor = createEditor({ - extensions: [ - defineExtension({ - name: "test-decorations", - decorations(_state, currentEditor) { - const blockId = currentEditor.firstBlock()?.id; - if (!blockId) { - return createDecorationSet([]); - } - - return createDecorationSet([ - { - type: "block", - blockId, - attributes: { active: true }, - }, - ]); - }, - }), - ], - }); - - const initialDecorations = editor.getDecorations(); - const repeatedDecorations = editor.getDecorations(); - expect(repeatedDecorations).toBe(initialDecorations); - - editor.apply( - [ - { - type: "insert-text", - blockId: editor.firstBlock()!.id, - offset: 0, - text: "trigger", - }, - ], - { origin: "user" }, - ); - - const autoRefreshedDecorations = editor.getDecorations(); - expect(autoRefreshedDecorations).not.toBe(initialDecorations); - expect(editor.getDecorations()).toBe(autoRefreshedDecorations); - - editor.requestDecorationUpdate(); - - const refreshedDecorations = editor.getDecorations(); - expect(refreshedDecorations).not.toBe(autoRefreshedDecorations); - expect(editor.getDecorations()).toBe(refreshedDecorations); - - editor.destroy(); - }); -}); diff --git a/packages/core/src/__tests__/inlineCompletion.test.ts b/packages/core/src/__tests__/inlineCompletion.test.ts new file mode 100644 index 0000000..efb35da --- /dev/null +++ b/packages/core/src/__tests__/inlineCompletion.test.ts @@ -0,0 +1,81 @@ +import { + INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE, + type BlockDecoration, + type InlineDecoration, +} from "@pen/types"; +import { describe, expect, it } from "vitest"; +import { createEditor, ensureInlineCompletionController } from "../index"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +describe("inline completion decorations", () => { + it("marks the suggestion block while an inline suggestion is visible", () => { + const editor = createEditor({ preset: noDefaultExtensionsPreset }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + + try { + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 5, + text: " there", + type: "inline", + }); + + const decorations = inlineCompletion.controller.buildDecorations(); + const blockDecoration = decorations.find( + (decoration): decoration is BlockDecoration => + decoration.type === "block", + ); + const inlineDecoration = decorations.find( + (decoration): decoration is InlineDecoration => + decoration.type === "inline", + ); + + expect(blockDecoration?.attributes).toMatchObject({ + [INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE]: true, + }); + expect(inlineDecoration?.attributes["data-suggestion-id"]).toBe( + "suggestion-1", + ); + } finally { + inlineCompletion.release(); + editor.destroy(); + } + }); + + it("keeps a block marker for block suggestions without inline anchors", () => { + const editor = createEditor({ preset: noDefaultExtensionsPreset }); + const blockId = editor.firstBlock()!.id; + const inlineCompletion = ensureInlineCompletionController(editor); + + try { + inlineCompletion.controller.showSuggestion({ + id: "suggestion-1", + blockId, + offset: 0, + text: "A new paragraph", + type: "block", + }); + + expect(inlineCompletion.controller.buildDecorations()).toEqual([ + { + type: "block", + blockId, + attributes: { + [INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE]: true, + }, + }, + ]); + } finally { + inlineCompletion.release(); + editor.destroy(); + } + }); +}); diff --git a/packages/core/src/__tests__/undoHistoryMetadata.test.ts b/packages/core/src/__tests__/undoHistoryMetadata.test.ts index 1544fa7..c1f6831 100644 --- a/packages/core/src/__tests__/undoHistoryMetadata.test.ts +++ b/packages/core/src/__tests__/undoHistoryMetadata.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { + type MutationGroupMetadata, type UndoHistoryMetadataController, + MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, } from "@pen/types"; import { createEditor } from "../index"; @@ -19,14 +21,16 @@ describe("@pen/core undo history metadata", () => { const restoredValues: Array = []; expect(controller).not.toBeNull(); - controller!.registerMetadataRestorer("test", (value: string | null) => { - restoredValues.push(value); - }); - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "A" }], - { origin: "user" }, + controller!.registerMetadataRestorer( + "test", + (value: string | null) => { + restoredValues.push(value); + }, ); + + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: "user", + }); controller!.setCurrentEntryMetadata("test", { before: "step-1-before", after: "step-1-after", @@ -38,10 +42,9 @@ describe("@pen/core undo history metadata", () => { before: "step-2-before", after: "step-2-after", }); - editor.apply( - [{ type: "insert-text", blockId, offset: 1, text: "B" }], - { origin: "user" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: "user", + }); expect(editor.undoManager.undo()).toBe(true); expect(restoredValues).toEqual(["step-2-before"]); @@ -59,14 +62,16 @@ describe("@pen/core undo history metadata", () => { const restoredValues: Array = []; expect(controller).not.toBeNull(); - controller!.registerMetadataRestorer("test", (value: string | null) => { - restoredValues.push(value); - }); - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "A" }], - { origin: "user" }, + controller!.registerMetadataRestorer( + "test", + (value: string | null) => { + restoredValues.push(value); + }, ); + + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: "user", + }); controller!.setCurrentEntryMetadata("test", { before: "step-1-before", after: "step-1-after", @@ -74,10 +79,9 @@ describe("@pen/core undo history metadata", () => { editor.undoManager.stopCapturing(); - editor.apply( - [{ type: "insert-text", blockId, offset: 1, text: "B" }], - { origin: "user" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: "user", + }); expect(editor.undoManager.undo()).toBe(true); expect(restoredValues).toEqual([null]); @@ -90,15 +94,15 @@ describe("@pen/core undo history metadata", () => { const editor = createEditor(); const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "A" }], - { origin: "user", undoGroupId: "group-1" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: "user", + undoGroupId: "group-1", + }); await vi.advanceTimersByTimeAsync(550); - editor.apply( - [{ type: "insert-text", blockId, offset: 1, text: "B" }], - { origin: "user", undoGroupId: "group-1" }, - ); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: "user", + undoGroupId: "group-1", + }); expect(editor.getBlock(blockId)?.textContent()).toBe("AB"); expect(editor.undoManager.undo()).toBe(true); @@ -108,4 +112,46 @@ describe("@pen/core undo history metadata", () => { editor.destroy(); vi.useRealTimers(); }); + + it("records structured origin group metadata and uses it as the undo group", async () => { + vi.useFakeTimers(); + const editor = createEditor(); + const controller = getHistoryMetadataController(editor); + const blockId = editor.firstBlock()!.id; + + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "A" }], { + origin: { + type: "ai", + groupId: "group-ai-1", + requestId: "request-1", + actorId: "assistant-1", + source: "test", + }, + }); + await vi.advanceTimersByTimeAsync(550); + editor.apply([{ type: "insert-text", blockId, offset: 1, text: "B" }], { + origin: { + type: "ai", + groupId: "group-ai-1", + requestId: "request-1", + }, + }); + + const metadata = + controller!.getCurrentEntryMetadata( + MUTATION_GROUP_METADATA_KEY, + ); + expect(metadata?.after).toMatchObject({ + groupId: "group-ai-1", + originType: "ai", + requestId: "request-1", + }); + expect(editor.getBlock(blockId)?.textContent()).toBe("AB"); + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe(""); + expect(editor.undoManager.undo()).toBe(false); + + editor.destroy(); + vi.useRealTimers(); + }); }); diff --git a/packages/core/src/editor/apply.ts b/packages/core/src/editor/apply.ts index 057eb0e..000e538 100644 --- a/packages/core/src/editor/apply.ts +++ b/packages/core/src/editor/apply.ts @@ -1,52 +1,17 @@ -import type { - DocumentOp, - OpOrigin, - PenDocument, - CRDTDocument, - CRDTAdapter, - CRDTEvent, - SchemaRegistry, - CRDTMap, - CRDTArray, - InsertBlockOp, - UpdateBlockOp, - DeleteBlockOp, - MoveBlockOp, - ConvertBlockOp, - SplitBlockOp, - MergeBlocksOp, - InsertTextOp, - DeleteTextOp, - FormatTextOp, - ReplaceTextOp, - InsertInlineNodeOp, - RemoveInlineNodeOp, - UpdateLayoutOp, - SetMetaOp, - CreateAppOp, - UpdateAppOp, - DeleteAppOp, - SetSelectionOp, - UpdateTableColumnsOp, -} from "@pen/types"; -import { generateId } from "@pen/types"; +import type { DocumentOp, OpOrigin, PenDocument, CRDTDocument, CRDTAdapter, CRDTEvent, SchemaRegistry, CRDTMap, CRDTArray, InsertBlockOp, UpdateBlockOp, DeleteBlockOp, MoveBlockOp, ConvertBlockOp, SplitBlockOp, MergeBlocksOp, InsertTextOp, DeleteTextOp, FormatTextOp, ReplaceTextOp, InsertInlineNodeOp, RemoveInlineNodeOp, UpdateLayoutOp, SetMetaOp, CreateAppOp, UpdateAppOp, DeleteAppOp, SetSelectionOp, UpdateTableColumnsOp } from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; import { resolveRuntimeContentType } from "../schema/contentType"; import type { SchemaEngineImpl } from "../schema/normalize"; -import { - type CRDTUnknownArray, - type CRDTUnknownMap, - getArrayProp, - getMapProp, - getStringProp, - getTableColumns, - getTableContent, - isCRDTMap, -} from "./crdtShapes"; +import { type CRDTUnknownArray, type CRDTUnknownMap, getArrayProp, getMapProp, getStringProp, getTableColumns, getTableContent, isCRDTMap } from "./crdtShapes"; import { DatabaseOpExecutor } from "./databaseOpExecutor"; import type { EventEmitter } from "./events"; import type { SelectionManagerImpl } from "./selection"; import { TableGridExecutor } from "./tableGridExecutor"; +import { blockExists, createMutableMap, getMutableBlockMap, getMutableAppMap, getOrCreateMapProp, getOrCreateStringArrayProp, removeBlockIdFromArray, removeBlockIdFromAllChildren, getTextContent, getInlineTextContent, opBlockId } from "./applySharedHelpers"; +import { applyInternal, executeOps, emitApplyBoundary, validateOp, resolvePosition, executeSingleOp } from "./applyPipelineRunner"; +import { insertBlock, updateBlock, deleteBlock, moveBlock, convertBlock, migrateTableToDatabase, splitBlock, mergeBlocks } from "./applyBlockOps"; +import { insertText, deleteText, formatText, replaceText, resolveMarks, insertInlineNode, removeInlineNode, setSelectionOp, updateLayout, createApp, updateApp, deleteApp, tableOp, databaseOp, clearTableState, clearDatabaseState, isDatabaseStructuralTableOp, getPreservedInlineDeltas, setMeta } from "./applyInlineAndMetaOps"; // Typed CRDT structure interfaces used by the op executor. type CRDTBlockMap = CRDTMap>; type MutableMap = CRDTUnknownMap & { delete(key: string): void }; @@ -114,10 +79,7 @@ export class ApplyPipeline { priority: number; }> = []; private _finalBeforeApplyHook: - | (( - ops: DocumentOp[], - options: { origin?: OpOrigin }, - ) => DocumentOp[]) + | ((ops: DocumentOp[], options: { origin?: OpOrigin }) => DocumentOp[]) | null = null; get suppressObserver(): boolean { @@ -224,147 +186,13 @@ export class ApplyPipeline { } private _applyInternal(ops: DocumentOp[], origin: OpOrigin): void { - if (this._applying) { - this._queue.push({ ops, origin }); - return; - } - - this._applying = true; - try { - this._executeOps(ops, origin); - while (this._queue.length > 0) { - const { ops: queued, origin: queuedOrigin } = - this._queue.shift()!; - this._executeOps(queued, queuedOrigin); - } - } finally { - this._applying = false; - } + applyInternal(this, ops, origin); } // ── Core Pipeline ──────────────────────────────────────── private _executeOps(ops: DocumentOp[], origin: OpOrigin): void { - // Let extensions transform ops before validation and execution. - let transformedOps = ops; - for (const { hook } of this._beforeApplyHooks) { - try { - transformedOps = hook(transformedOps, { origin }); - } catch (err) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_005", - level: "error", - source: "apply", - message: "onBeforeApply hook threw", - remediation: - "Update the onBeforeApply hook to handle incoming ops defensively and " + - "always return a valid DocumentOp array.", - error: err, - }); - } - } - if (this._finalBeforeApplyHook) { - try { - transformedOps = this._finalBeforeApplyHook(transformedOps, { origin }); - } catch (err) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_007", - level: "error", - source: "apply", - message: "final apply boundary hook threw", - remediation: - "Update the final apply boundary hook to handle incoming ops defensively and " + - "always return a valid DocumentOp array.", - error: err, - }); - } - } - - this._emitApplyBoundary({ - phase: "before", - ops: transformedOps, - origin, - applied: false, - }); - - const affectedBlocks: string[] = []; - const validatedOps: DocumentOp[] = []; - const pendingBlockIds = new Set(); - - for (const op of transformedOps) { - const blockId = this._opBlockId(op); - - if (!this._validateOp(op)) continue; - - if (op.type === "insert-block") { - pendingBlockIds.add(op.blockId); - } - - if ( - blockId && - !this._blockExists(blockId) && - !pendingBlockIds.has(blockId) && - op.type !== "insert-block" - ) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_003", - level: "warn", - source: "apply", - message: `apply: skipping ${op.type} for non-existent block "${blockId}"`, - }); - continue; - } - - validatedOps.push(op); - } - - if (validatedOps.length === 0) { - this._emitApplyBoundary({ - phase: "after", - ops: transformedOps, - origin, - applied: false, - }); - return; - } - - this._suppressObserver = true; - - try { - this._adapter.transact( - this._crdtDoc, - () => { - for (const op of validatedOps) { - const affected = this._executeSingleOp(op); - affectedBlocks.push(...affected); - } - - for (const blockId of affectedBlocks) { - this._engine.markDirty(blockId); - } - - this._engine.normalizeDirty(); - }, - origin, - ); - } finally { - this._suppressObserver = false; - } - - const event: CRDTEvent = { - origin, - affectedBlocks: [...new Set(affectedBlocks)], - ops: validatedOps, - timestamp: Date.now(), - }; - - this._onDidApply?.(event); - this._emitApplyBoundary({ - phase: "after", - ops: validatedOps, - origin, - applied: true, - }); + executeOps(this, ops, origin); } private _emitApplyBoundary(event: { @@ -373,848 +201,190 @@ export class ApplyPipeline { origin: OpOrigin; applied: boolean; }): void { - for (const hook of this._applyBoundaryHooks) { - try { - hook(event); - } catch (err) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_008", - level: "error", - source: "apply", - message: "apply boundary hook threw", - remediation: - "Update the apply boundary hook to avoid throwing during transaction lifecycle notifications.", - error: err, - }); - } - } + emitApplyBoundary(this, event); } // ── Schema Validation ──────────────────────────────────── private _validateOp(op: DocumentOp): boolean { - switch (op.type) { - case "insert-block": { - const schema = this._registry.resolve(op.blockType); - if (!schema) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - message: `Unknown block type: "${op.blockType}"`, - op, - }); - return false; - } - return true; - } - case "convert-block": { - const schema = this._registry.resolve(op.newType); - if (!schema) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - message: `Unknown block type: "${op.newType}"`, - op, - }); - return false; - } - return true; - } - case "insert-inline-node": { - const schema = this._registry.resolveInline(op.nodeType); - if (!schema || schema.kind !== "node") { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_002", - level: "warn", - source: "apply", - message: `Unknown inline node type: "${op.nodeType}"`, - op, - }); - return false; - } - return true; - } - default: - return true; - } + return validateOp(this, op); } // ── Position Resolution ────────────────────────────────── _resolvePosition(position: import("@pen/types").Position): number { - const blockOrder = this._doc.blockOrder; - - if (position === "first") return 0; - if (position === "last") return blockOrder.length; - - if (typeof position === "object" && "after" in position) { - for (let i = 0; i < blockOrder.length; i++) { - if ((blockOrder.get(i) as string) === position.after) - return i + 1; - } - return blockOrder.length; - } - - if (typeof position === "object" && "before" in position) { - for (let i = 0; i < blockOrder.length; i++) { - if ((blockOrder.get(i) as string) === position.before) return i; - } - return 0; - } - - if (typeof position === "object" && "parent" in position) { - const parentMap = this.blocks.get(position.parent); - if (!parentMap) return blockOrder.length; - const children = parentMap.get("children") as - | CRDTArray - | undefined; - if (!children) return 0; - return Math.min(position.index, children.length); - } - - return blockOrder.length; + return resolvePosition(this, position); } // ── Op Dispatch ────────────────────────────────────────── private _executeSingleOp(op: DocumentOp): string[] { - switch (op.type) { - case "insert-block": - return this._insertBlock(op); - case "update-block": - return this._updateBlock(op); - case "delete-block": - return this._deleteBlock(op); - case "move-block": - return this._moveBlock(op); - case "convert-block": - return this._convertBlock(op); - case "split-block": - return this._splitBlock(op); - case "merge-blocks": - return this._mergeBlocks(op); - case "insert-text": - return this._insertText(op); - case "delete-text": - return this._deleteText(op); - case "format-text": - return this._formatText(op); - case "replace-text": - return this._replaceText(op); - case "insert-inline-node": - return this._insertInlineNode(op); - case "remove-inline-node": - return this._removeInlineNode(op); - case "set-selection": - return this._setSelection(op); - case "update-layout": - return this._updateLayout(op); - case "create-app": - return this._createApp(op); - case "update-app": - return this._updateApp(op); - case "delete-app": - return this._deleteApp(op); - case "insert-table-row": - case "delete-table-row": - case "insert-table-column": - case "delete-table-column": - case "merge-table-cells": - case "split-table-cell": - case "insert-table-cell-text": - case "delete-table-cell-text": - case "format-table-cell-text": - case "update-table-columns": - return this._tableOp(op); - case "database-add-column": - case "database-update-column": - case "database-convert-column": - case "database-remove-column": - case "database-insert-row": - case "database-update-cell": - case "database-delete-row": - case "database-delete-rows": - case "database-duplicate-row": - case "database-move-row": - case "database-add-view": - case "database-update-view": - case "database-remove-view": - case "database-set-active-view": - case "database-update-select-options": - return this._databaseOp(op); - case "set-meta": - return this._setMeta(op); - default: - return []; - } + return executeSingleOp(this, op); } // ── Block Ops ──────────────────────────────────────────── private _insertBlock(op: InsertBlockOp): string[] { - const schema = this._registry.resolve(op.blockType); - if (!schema) return []; - - const contentType = resolveRuntimeContentType(schema); - const blockMap = this._adapter.initBlockMap( - this._crdtDoc, - op.blockId, - op.blockType, - contentType, - ) as MutableMap; - - if (op.props && Object.keys(op.props).length > 0) { - const propsMap = this._getOrCreateMapProp(blockMap, "props"); - for (const [key, value] of Object.entries(op.props)) { - propsMap.set(key, value); - } - } - - if ((schema as { content: unknown }).content === "subdocument") { - const propsMap = this._getOrCreateMapProp(blockMap, "props"); - const subdocument = blockMap.get("subdocument") as - | { guid?: unknown } - | undefined; - if ( - subdocument && - typeof subdocument === "object" && - typeof subdocument.guid === "string" - ) { - propsMap.set("subdocumentGuid", subdocument.guid); - } - } - - if (typeof op.position === "object" && "parent" in op.position) { - const parentMap = this._getMutableBlockMap(op.position.parent); - if (parentMap) { - const children = this._getOrCreateStringArrayProp(parentMap, "children"); - const idx = Math.min(op.position.index, children.length); - children.insert(idx, [op.blockId]); - } - } else { - const idx = this._resolvePosition(op.position); - this.mutableBlockOrder.insert(idx, [op.blockId]); - } - - if ((schema as { content: unknown }).content === "database") { - this._databaseOps.seedDatabaseBlock(blockMap); - } - - return [op.blockId]; + return insertBlock(this, op); } private _updateBlock(op: UpdateBlockOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const propsMap = this._getOrCreateMapProp(blockMap, "props"); - - for (const [key, value] of Object.entries(op.props)) { - if (value === undefined || value === null) { - propsMap.delete(key); - } else { - propsMap.set(key, value); - } - } - - return [op.blockId]; + return updateBlock(this, op); } private _deleteBlock(op: DeleteBlockOp): string[] { - this.mutableBlocks.delete(op.blockId); - this._removeBlockIdFromArray(this.mutableBlockOrder, op.blockId); - this._removeBlockIdFromAllChildren(op.blockId); - - return [op.blockId]; + return deleteBlock(this, op); } private _moveBlock(op: MoveBlockOp): string[] { - this._removeBlockIdFromArray(this.mutableBlockOrder, op.blockId, true); - this._removeBlockIdFromAllChildren(op.blockId); - - // Insert at new position - if (typeof op.position === "object" && "parent" in op.position) { - const parentMap = this._getMutableBlockMap(op.position.parent); - if (parentMap) { - const children = this._getOrCreateStringArrayProp(parentMap, "children"); - const idx = Math.min(op.position.index, children.length); - children.insert(idx, [op.blockId]); - } - } else { - const idx = this._resolvePosition(op.position); - this.mutableBlockOrder.insert(idx, [op.blockId]); - } - - return [op.blockId]; + return moveBlock(this, op); } private _convertBlock(op: ConvertBlockOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const oldType = blockMap.get("type") as string; - const oldSchema = this._registry.resolve(oldType); - const newSchema = this._registry.resolve(op.newType); - if (!newSchema) return []; - - blockMap.set("type", op.newType); - - const propsMap = getMapProp(blockMap, "props"); - if (propsMap) { - const mutablePropsMap = propsMap as MutableMap; - const newPropKeys = new Set( - Object.keys(newSchema.propSchema ?? {}), - ); - for (const key of [...(mutablePropsMap.keys?.() ?? [])]) { - if (!newPropKeys.has(key)) { - mutablePropsMap.delete(key); - } - } - } - - if (op.newProps) { - const props = this._getOrCreateMapProp(blockMap, "props"); - for (const [key, value] of Object.entries(op.newProps)) { - props.set(key, value); - } - } - - const oldContent = oldSchema?.content; - const newContent = newSchema.content; - const preservedInlineDeltas = - oldContent === "inline" - ? this._getPreservedInlineDeltas(this._getTextContent(blockMap)) - : []; - - if (oldContent === "inline" && newContent !== "inline") { - if ( - newContent === "none" || - newContent === "table" || - Array.isArray(newContent) - ) { - blockMap.delete("content"); - } - } else if (oldContent !== "inline" && newContent === "inline") { - const ytext = this._adapter.createText(); - blockMap.set("content", ytext); - } - - const targetContent = resolveRuntimeContentType(newSchema); - if (targetContent !== "database") { - this._clearDatabaseState(blockMap); - } - if (targetContent === "table") { - blockMap.delete("tableColumns"); - } else if (targetContent !== "database") { - this._clearTableState(blockMap); - } - - if (targetContent === "table" && !getTableContent(blockMap)) { - this._tableGrid.seedTableBlock(blockMap, { - rowCount: 2, - colCount: 2, - preservedInlineDeltas, - }); - } - - if (targetContent === "database") { - if (oldType === "table") { - this._migrateTableToDatabase(blockMap, propsMap); - } - this._databaseOps.seedDatabaseBlock(blockMap); - } - - return [op.blockId]; + return convertBlock(this, op); } private _migrateTableToDatabase( blockMap: MutableMap, propsMap: CRDTUnknownMap | null, ): void { - const tableContent = getTableContent(blockMap); - if (!tableContent) { - return; - } - - const hasHeaderRow = propsMap?.get("hasHeaderRow") !== false; - const existingColumns = getTableColumns(blockMap); - if (!existingColumns || existingColumns.length === 0) { - const columnCount = this._tableGrid.resolveGridColumnCount(blockMap); - const columns = Array.from({ length: columnCount }, (_, index) => { - const title = - hasHeaderRow && tableContent.length > 0 - ? this._tableGrid.readTableCellText( - tableContent.get(0) as CRDTUnknownMap, - index, - ).trim() || `Column ${index + 1}` - : `Column ${index + 1}`; - return { - id: `column-${index + 1}`, - title, - type: "text" as const, - }; - }); - if (columns.length > 0) { - this._tableGrid.setStructuredTableColumns(blockMap, columns); - } - } - - if (hasHeaderRow && tableContent.length > 0) { - tableContent.delete(0, 1); - } - - for (let rowIndex = 0; rowIndex < tableContent.length; rowIndex++) { - const row = tableContent.get(rowIndex); - if (!row || !isCRDTMap(row)) { - continue; - } - if (!getStringProp(row, "id")) { - row.set("id", generateId()); - } - } + migrateTableToDatabase(this, blockMap, propsMap); } private _splitBlock(op: SplitBlockOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const content = this._getTextContent(blockMap); - if (!content) return []; - - const oldType = blockMap.get("type") as string; - const newType = op.newBlockType ?? oldType; - const schema = this._registry.resolve(newType); - - const deltas = content.toDelta(); - const tailDeltas: Array<{ - insert: string | object; - attributes?: Record; - }> = []; - let pos = 0; - - for (const delta of deltas) { - const len = - typeof delta.insert === "string" ? delta.insert.length : 1; - if (pos + len <= op.offset) { - pos += len; - continue; - } - - if (pos < op.offset) { - const splitAt = op.offset - pos; - const tailText = (delta.insert as string).slice(splitAt); - if (tailText) { - tailDeltas.push({ - insert: tailText, - attributes: delta.attributes, - }); - } - } else { - tailDeltas.push(delta); - } - pos += len; - } - - const totalLength = content.length; - if (op.offset < totalLength) { - content.delete(op.offset, totalLength - op.offset); - } - - // Initialize the new block through the adapter so shared CRDT state stays consistent. - const contentType = resolveRuntimeContentType(schema); - const newBlockMap = this._adapter.initBlockMap( - this._crdtDoc, - op.newBlockId, - newType, - contentType, - ) as MutableMap; - - const newContent = this._getTextContent(newBlockMap); - if (newContent) { - for (const delta of tailDeltas) { - newContent.insert( - newContent.length, - delta.insert as string, - delta.attributes, - ); - } - } - - // Copy parentId if present - const propsMap = getMapProp(blockMap, "props"); - if (propsMap?.get?.("parentId")) { - const newProps = getMapProp(newBlockMap, "props"); - if (newProps) { - newProps.set("parentId", propsMap.get("parentId")); - } - } - - // Insert new block right after original in blockOrder - for (let i = 0; i < this.blockOrder.length; i++) { - if (this.blockOrder.get(i) === op.blockId) { - this.mutableBlockOrder.insert(i + 1, [op.newBlockId]); - break; - } - } - - return [op.blockId, op.newBlockId]; + return splitBlock(this, op); } private _mergeBlocks(op: MergeBlocksOp): string[] { - const targetMap = this._getMutableBlockMap(op.targetBlockId); - const sourceMap = this._getMutableBlockMap(op.sourceBlockId); - if (!targetMap || !sourceMap) return []; - - const targetContent = this._getTextContent(targetMap); - const sourceContent = this._getTextContent(sourceMap); - - if ( - targetContent && - sourceContent && - typeof sourceContent.toDelta === "function" - ) { - if ( - targetContent.length === 1 && - targetContent.toString() === ZERO_WIDTH_SPACE - ) { - targetContent.delete(0, 1); - } - - const deltas = sourceContent.toDelta(); - for (const delta of deltas) { - if ( - typeof delta.insert === "string" && - delta.insert === ZERO_WIDTH_SPACE - ) { - continue; - } - targetContent.insert( - targetContent.length, - delta.insert as string, - delta.attributes, - ); - } - - while (targetContent.length > 1) { - const placeholderOffset = targetContent - .toString() - .indexOf(ZERO_WIDTH_SPACE); - if (placeholderOffset < 0) break; - targetContent.delete(placeholderOffset, 1); - } - } - - this.mutableBlocks.delete(op.sourceBlockId); - for (let i = this.mutableBlockOrder.length - 1; i >= 0; i--) { - if (this.blockOrder.get(i) === op.sourceBlockId) { - this.mutableBlockOrder.delete(i, 1); - break; - } - } - - return [op.targetBlockId, op.sourceBlockId]; + return mergeBlocks(this, op); } // ── Text Ops ───────────────────────────────────────────── private _insertText(op: InsertTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { - content.delete(0, 1); - } - - const marks = op.marks ? this._resolveMarks(op.marks) : undefined; - content.insert(op.offset, op.text, marks); - return [op.blockId]; + return insertText(this, op); } private _deleteText(op: DeleteTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - content.delete(op.offset, op.length); - return [op.blockId]; + return deleteText(this, op); } private _formatText(op: FormatTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - content.format(op.offset, op.length, op.marks); - return [op.blockId]; + return formatText(this, op); } private _replaceText(op: ReplaceTextOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { - content.delete(0, 1); - } - - content.delete(op.offset, op.length); - const marks = op.marks ? this._resolveMarks(op.marks) : undefined; - content.insert(op.offset, op.text, marks); - return [op.blockId]; + return replaceText(this, op); } private _resolveMarks( marks: Record, ): Record { - const resolved: Record = {}; - for (const [type, value] of Object.entries(marks)) { - const schema = this._registry.resolveInline(type); - if (!schema) continue; - resolved[type] = value; - } - return resolved; + return resolveMarks(this, marks); } // ── Inline Node Ops ────────────────────────────────────── private _insertInlineNode(op: InsertInlineNodeOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getInlineTextContent(blockMap); - if (!content) return []; - - content.insertEmbed(op.offset, { - type: op.nodeType, - ...op.props, - }); - return [op.blockId]; + return insertInlineNode(this, op); } private _removeInlineNode(op: RemoveInlineNodeOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - const content = this._getTextContent(blockMap); - if (!content) return []; - - content.delete(op.offset, 1); - return [op.blockId]; + return removeInlineNode(this, op); } // ── Selection Op ───────────────────────────────────────── private _setSelection(op: SetSelectionOp): string[] { - this._selection.setSelection(op.selection); - return []; + return setSelectionOp(this, op); } // ── Layout Op ──────────────────────────────────────────── private _updateLayout(op: UpdateLayoutOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const layoutMap = this._getOrCreateMapProp(blockMap, "layout"); - - for (const [key, value] of Object.entries(op.layout)) { - if (value === undefined || value === null) { - layoutMap.delete(key); - } else { - layoutMap.set(key, value); - } - } - - return [op.blockId]; + return updateLayout(this, op); } // ── App Ops ────────────────────────────────────────────── private _createApp(op: CreateAppOp): string[] { - const appMap = this._createMutableMap(); - appMap.set("type", op.appType); - appMap.set("placement", op.placement); - - if (op.config && Object.keys(op.config).length > 0) { - const configMap = this._createMutableMap(); - for (const [key, value] of Object.entries(op.config)) { - configMap.set(key, value); - } - appMap.set("config", configMap); - } - - this.mutableApps.set(op.appId, appMap); - return []; + return createApp(this, op); } private _updateApp(op: UpdateAppOp): string[] { - const appMap = this._getMutableAppMap(op.appId); - if (!appMap) return []; - - const configMap = this._getOrCreateMapProp(appMap, "config"); - - for (const [key, value] of Object.entries(op.patch)) { - if (value === undefined || value === null) { - configMap.delete(key); - } else { - configMap.set(key, value); - } - } - return []; + return updateApp(this, op); } private _deleteApp(op: DeleteAppOp): string[] { - this.mutableApps.delete(op.appId); - return []; + return deleteApp(this, op); } // ── Table Ops ──────────────────────────────────────────── private _tableOp(op: DocumentOp): string[] { - const tableOp = op as { blockId: string; type: string }; - const blockMap = this._getMutableBlockMap(tableOp.blockId); - if (!blockMap) return []; - - const blockType = blockMap.get("type"); - if (blockType === "database") { - if (op.type === "update-table-columns") { - return this._databaseOps.replaceColumns( - blockMap, - (op as UpdateTableColumnsOp).columns, - ) - ? [tableOp.blockId] - : []; - } - - if (this._isDatabaseStructuralTableOp(op.type)) { - this._emitter.emit("diagnostic", { - code: "PEN_APPLY_006", - level: "warn", - source: "apply", - message: `apply: skipping ${op.type} for database block "${tableOp.blockId}"`, - remediation: - "Use database operations for structural database changes so row ids, column schema, and views stay in sync.", - op, - }); - return []; - } - } - - return this._tableGrid.execute(blockMap, op); + return tableOp(this, op); } private _databaseOp(op: DocumentOp): string[] { - const databaseOp = op as { type: string; blockId: string }; - const blockMap = this._getMutableBlockMap(databaseOp.blockId); - if (!blockMap) return []; - - return this._databaseOps.execute(blockMap, op); + return databaseOp(this, op); } private _clearTableState(blockMap: MutableMap): void { - blockMap.delete("tableContent"); - blockMap.delete("tableColumns"); + clearTableState(this, blockMap); } private _clearDatabaseState(blockMap: MutableMap): void { - blockMap.delete("databaseViews"); - blockMap.delete("databasePrimaryViewId"); + clearDatabaseState(this, blockMap); } private _isDatabaseStructuralTableOp(type: string): boolean { - return ( - type === "insert-table-row" || - type === "delete-table-row" || - type === "insert-table-column" || - type === "delete-table-column" || - type === "merge-table-cells" || - type === "split-table-cell" - ); + return isDatabaseStructuralTableOp(this, type); } - private _getPreservedInlineDeltas( - content: CRDTText | undefined, - ): Array<{ + private _getPreservedInlineDeltas(content: CRDTText | undefined): Array<{ insert: string; attributes?: Record; }> { - if (!content || typeof content.toDelta !== "function") { - return []; - } - - return content - .toDelta() - .filter( - (delta): delta is { - insert: string; - attributes?: Record; - } => - typeof delta.insert === "string" && delta.insert !== ZERO_WIDTH_SPACE, - ); + return getPreservedInlineDeltas(this, content); } + // ── Meta Op ────────────────────────────────────────────── private _setMeta(op: SetMetaOp): string[] { - const blockMap = this._getMutableBlockMap(op.blockId); - if (!blockMap) return []; - - const metaMap = this._getOrCreateMapProp(blockMap, "meta"); - - // Persist metadata as plain JSON so adapters can round-trip it predictably. - if (op.data === null) { - metaMap.delete(op.namespace); - } else { - metaMap.set(op.namespace, op.data); - } - - return [op.blockId]; + return setMeta(this, op); } // ── Helpers ────────────────────────────────────────────── private _blockExists(blockId: string): boolean { - return this.blocks.has(blockId); + return blockExists(this, blockId); } private _createMutableMap(): MutableMap { - return this._adapter.createMap() as MutableMap; + return createMutableMap(this); } private _getMutableBlockMap(blockId: string): MutableMap | null { - return (this.blocks.get(blockId) as unknown as MutableMap | undefined) ?? null; + return getMutableBlockMap(this, blockId); } private _getMutableAppMap(appId: string): MutableMap | null { - return (this.apps.get(appId) as unknown as MutableMap | undefined) ?? null; + return getMutableAppMap(this, appId); } - private _getOrCreateMapProp(container: CRDTUnknownMap, key: string): MutableMap { - const existing = getMapProp(container, key); - if (existing) { - return existing as MutableMap; - } - const map = this._createMutableMap(); - container.set(key, map); - return map; + private _getOrCreateMapProp( + container: CRDTUnknownMap, + key: string, + ): MutableMap { + return getOrCreateMapProp(this, container, key); } private _getOrCreateStringArrayProp( container: CRDTUnknownMap, key: string, ): MutableStringArray { - const existing = getArrayProp(container, key); - if (existing) { - return existing as MutableStringArray; - } - const array = this._adapter.createArray() as MutableStringArray; - container.set(key, array); - return array; + return getOrCreateStringArrayProp(this, container, key); } private _removeBlockIdFromArray( @@ -1222,58 +392,25 @@ export class ApplyPipeline { blockId: string, stopAfterFirst = false, ): void { - for (let index = array.length - 1; index >= 0; index--) { - if (array.get(index) !== blockId) { - continue; - } - array.delete(index, 1); - if (stopAfterFirst) { - return; - } - } + removeBlockIdFromArray(this, array, blockId, stopAfterFirst); } private _removeBlockIdFromAllChildren(blockId: string): void { - for (const [, parentMap] of this.blocks.entries()) { - const children = getArrayProp( - parentMap as unknown as CRDTUnknownMap, - "children", - ); - if (!children) { - continue; - } - this._removeBlockIdFromArray(children as MutableStringArray, blockId); - } + removeBlockIdFromAllChildren(this, blockId); } private _getTextContent(blockMap: CRDTUnknownMap): CRDTText | undefined { - const content = blockMap.get("content"); - return content && - typeof content === "object" && - typeof (content as { insert?: unknown }).insert === "function" && - typeof (content as { delete?: unknown }).delete === "function" && - typeof (content as { format?: unknown }).format === "function" && - typeof (content as { toDelta?: unknown }).toDelta === "function" && - typeof (content as { toString?: unknown }).toString === "function" && - typeof (content as { length?: unknown }).length === "number" - ? (content as CRDTText) - : undefined; + return getTextContent(this, blockMap); } - private _getInlineTextContent(blockMap: CRDTUnknownMap): CRDTInlineText | undefined { - const content = this._getTextContent(blockMap); - return content && - typeof (content as { insertEmbed?: unknown }).insertEmbed === "function" - ? (content as CRDTInlineText) - : undefined; + private _getInlineTextContent( + blockMap: CRDTUnknownMap, + ): CRDTInlineText | undefined { + return getInlineTextContent(this, blockMap); } private _opBlockId(op: DocumentOp): string | null { - if ("blockId" in op) return (op as { blockId: string }).blockId; - if ("targetBlockId" in op) - return (op as { targetBlockId: string }).targetBlockId; - if ("appId" in op) return null; - return null; + return opBlockId(this, op); } updateDocument( diff --git a/packages/core/src/editor/applyBlockOps.ts b/packages/core/src/editor/applyBlockOps.ts new file mode 100644 index 0000000..e5680b0 --- /dev/null +++ b/packages/core/src/editor/applyBlockOps.ts @@ -0,0 +1,436 @@ +import type { + DocumentOp, + OpOrigin, + InsertBlockOp, + UpdateBlockOp, + DeleteBlockOp, + MoveBlockOp, + ConvertBlockOp, + SplitBlockOp, + MergeBlocksOp, + InsertTextOp, + DeleteTextOp, + FormatTextOp, + ReplaceTextOp, + InsertInlineNodeOp, + RemoveInlineNodeOp, + UpdateLayoutOp, + SetMetaOp, + CreateAppOp, + UpdateAppOp, + DeleteAppOp, + SetSelectionOp, + UpdateTableColumnsOp, + CRDTArray, +} from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; +import { resolveRuntimeContentType } from "../schema/contentType"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, + getStringProp, + getTableColumns, + getTableContent, + isCRDTMap, +} from "./crdtShapes"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} +const ZERO_WIDTH_SPACE = "\u200B"; + + +export function insertBlock(pipeline: ApplyPipeline, op: InsertBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const schema = self._registry.resolve(op.blockType); +if (!schema) return []; + +const contentType = resolveRuntimeContentType(schema); +const blockMap = self._adapter.initBlockMap( + self._crdtDoc, + op.blockId, + op.blockType, + contentType, +) as MutableMap; + +if (op.props && Object.keys(op.props).length > 0) { + const propsMap = self._getOrCreateMapProp(blockMap, "props"); + for (const [key, value] of Object.entries(op.props)) { + propsMap.set(key, value); + } +} + +if ((schema as { content: unknown }).content === "subdocument") { + const propsMap = self._getOrCreateMapProp(blockMap, "props"); + const subdocument = blockMap.get("subdocument") as + | { guid?: unknown } + | undefined; + if ( + subdocument && + typeof subdocument === "object" && + typeof subdocument.guid === "string" + ) { + propsMap.set("subdocumentGuid", subdocument.guid); + } +} + +if (typeof op.position === "object" && "parent" in op.position) { + const parentMap = self._getMutableBlockMap(op.position.parent); + if (parentMap) { + const children = self._getOrCreateStringArrayProp( + parentMap, + "children", + ); + const idx = Math.min(op.position.index, children.length); + children.insert(idx, [op.blockId]); + } +} else { + const idx = self._resolvePosition(op.position); + self.mutableBlockOrder.insert(idx, [op.blockId]); +} + +if ((schema as { content: unknown }).content === "database") { + self._databaseOps.seedDatabaseBlock(blockMap); +} + +return [op.blockId]; +} + +export function updateBlock(pipeline: ApplyPipeline, op: UpdateBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const propsMap = self._getOrCreateMapProp(blockMap, "props"); + +for (const [key, value] of Object.entries(op.props)) { + if (value === undefined || value === null) { + propsMap.delete(key); + } else { + propsMap.set(key, value); + } +} + +return [op.blockId]; +} + +export function deleteBlock(pipeline: ApplyPipeline, op: DeleteBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self.mutableBlocks.delete(op.blockId); +self._removeBlockIdFromArray(self.mutableBlockOrder, op.blockId); +self._removeBlockIdFromAllChildren(op.blockId); + +return [op.blockId]; +} + +export function moveBlock(pipeline: ApplyPipeline, op: MoveBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self._removeBlockIdFromArray(self.mutableBlockOrder, op.blockId, true); +self._removeBlockIdFromAllChildren(op.blockId); + +// Insert at new position +if (typeof op.position === "object" && "parent" in op.position) { + const parentMap = self._getMutableBlockMap(op.position.parent); + if (parentMap) { + const children = self._getOrCreateStringArrayProp( + parentMap, + "children", + ); + const idx = Math.min(op.position.index, children.length); + children.insert(idx, [op.blockId]); + } +} else { + const idx = self._resolvePosition(op.position); + self.mutableBlockOrder.insert(idx, [op.blockId]); +} + +return [op.blockId]; +} + +export function convertBlock(pipeline: ApplyPipeline, op: ConvertBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const oldType = blockMap.get("type") as string; +const oldSchema = self._registry.resolve(oldType); +const newSchema = self._registry.resolve(op.newType); +if (!newSchema) return []; + +blockMap.set("type", op.newType); + +const propsMap = getMapProp(blockMap, "props"); +if (propsMap) { + const mutablePropsMap = propsMap as MutableMap; + const newPropKeys = new Set( + Object.keys(newSchema.propSchema ?? {}), + ); + for (const key of [...(mutablePropsMap.keys?.() ?? [])]) { + if (!newPropKeys.has(key)) { + mutablePropsMap.delete(key); + } + } +} + +if (op.newProps) { + const props = self._getOrCreateMapProp(blockMap, "props"); + for (const [key, value] of Object.entries(op.newProps)) { + props.set(key, value); + } +} + +const oldContent = oldSchema?.content; +const newContent = newSchema.content; +const preservedInlineDeltas = + oldContent === "inline" + ? self._getPreservedInlineDeltas(self._getTextContent(blockMap)) + : []; + +if (oldContent === "inline" && newContent !== "inline") { + if ( + newContent === "none" || + newContent === "table" || + Array.isArray(newContent) + ) { + blockMap.delete("content"); + } +} else if (oldContent !== "inline" && newContent === "inline") { + const ytext = self._adapter.createText(); + blockMap.set("content", ytext); +} + +const targetContent = resolveRuntimeContentType(newSchema); +if (targetContent !== "database") { + self._clearDatabaseState(blockMap); +} +if (targetContent === "table") { + blockMap.delete("tableColumns"); +} else if (targetContent !== "database") { + self._clearTableState(blockMap); +} + +if (targetContent === "table" && !getTableContent(blockMap)) { + self._tableGrid.seedTableBlock(blockMap, { + rowCount: 2, + colCount: 2, + preservedInlineDeltas, + }); +} + +if (targetContent === "database") { + if (oldType === "table") { + self._migrateTableToDatabase(blockMap, propsMap); + } + self._databaseOps.seedDatabaseBlock(blockMap); +} + +return [op.blockId]; +} + +export function migrateTableToDatabase(pipeline: ApplyPipeline, + blockMap: MutableMap, + propsMap: CRDTUnknownMap | null, +): void { + const self = pipeline as ApplyPipelineRuntime; +const tableContent = getTableContent(blockMap); +if (!tableContent) { + return; +} + +const hasHeaderRow = propsMap?.get("hasHeaderRow") !== false; +const existingColumns = getTableColumns(blockMap); +if (!existingColumns || existingColumns.length === 0) { + const columnCount = + self._tableGrid.resolveGridColumnCount(blockMap); + const columns = Array.from({ length: columnCount }, (_, index) => { + const title = + hasHeaderRow && tableContent.length > 0 + ? self._tableGrid + .readTableCellText( + tableContent.get(0) as CRDTUnknownMap, + index, + ) + .trim() || `Column ${index + 1}` + : `Column ${index + 1}`; + return { + id: `column-${index + 1}`, + title, + type: "text" as const, + }; + }); + if (columns.length > 0) { + self._tableGrid.setStructuredTableColumns(blockMap, columns); + } +} + +if (hasHeaderRow && tableContent.length > 0) { + tableContent.delete(0, 1); +} + +for (let rowIndex = 0; rowIndex < tableContent.length; rowIndex++) { + const row = tableContent.get(rowIndex); + if (!row || !isCRDTMap(row)) { + continue; + } + if (!getStringProp(row, "id")) { + row.set("id", generateId()); + } +} +} + +export function splitBlock(pipeline: ApplyPipeline, op: SplitBlockOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const content = self._getTextContent(blockMap); +if (!content) return []; + +const oldType = blockMap.get("type") as string; +const newType = op.newBlockType ?? oldType; +const schema = self._registry.resolve(newType); + +const deltas = content.toDelta(); +const tailDeltas: Array<{ + insert: string | object; + attributes?: Record; +}> = []; +let pos = 0; + +for (const delta of deltas) { + const len = + typeof delta.insert === "string" ? delta.insert.length : 1; + if (pos + len <= op.offset) { + pos += len; + continue; + } + + if (pos < op.offset) { + const splitAt = op.offset - pos; + const tailText = (delta.insert as string).slice(splitAt); + if (tailText) { + tailDeltas.push({ + insert: tailText, + attributes: delta.attributes, + }); + } + } else { + tailDeltas.push(delta); + } + pos += len; +} + +const totalLength = content.length; +if (op.offset < totalLength) { + content.delete(op.offset, totalLength - op.offset); +} + +// Initialize the new block through the adapter so shared CRDT state stays consistent. +const contentType = resolveRuntimeContentType(schema); +const newBlockMap = self._adapter.initBlockMap( + self._crdtDoc, + op.newBlockId, + newType, + contentType, +) as MutableMap; + +const newContent = self._getTextContent(newBlockMap); +if (newContent) { + for (const delta of tailDeltas) { + newContent.insert( + newContent.length, + delta.insert as string, + delta.attributes, + ); + } +} + +// Copy parentId if present +const propsMap = getMapProp(blockMap, "props"); +if (propsMap?.get?.("parentId")) { + const newProps = getMapProp(newBlockMap, "props"); + if (newProps) { + newProps.set("parentId", propsMap.get("parentId")); + } +} + +// Insert new block right after original in blockOrder +for (let i = 0; i < self.blockOrder.length; i++) { + if (self.blockOrder.get(i) === op.blockId) { + self.mutableBlockOrder.insert(i + 1, [op.newBlockId]); + break; + } +} + +return [op.blockId, op.newBlockId]; +} + +export function mergeBlocks(pipeline: ApplyPipeline, op: MergeBlocksOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const targetMap = self._getMutableBlockMap(op.targetBlockId); +const sourceMap = self._getMutableBlockMap(op.sourceBlockId); +if (!targetMap || !sourceMap) return []; + +const targetContent = self._getTextContent(targetMap); +const sourceContent = self._getTextContent(sourceMap); + +if ( + targetContent && + sourceContent && + typeof sourceContent.toDelta === "function" +) { + if ( + targetContent.length === 1 && + targetContent.toString() === ZERO_WIDTH_SPACE + ) { + targetContent.delete(0, 1); + } + + const deltas = sourceContent.toDelta(); + for (const delta of deltas) { + if ( + typeof delta.insert === "string" && + delta.insert === ZERO_WIDTH_SPACE + ) { + continue; + } + targetContent.insert( + targetContent.length, + delta.insert as string, + delta.attributes, + ); + } + + while (targetContent.length > 1) { + const placeholderOffset = targetContent + .toString() + .indexOf(ZERO_WIDTH_SPACE); + if (placeholderOffset < 0) break; + targetContent.delete(placeholderOffset, 1); + } +} + +self.mutableBlocks.delete(op.sourceBlockId); +for (let i = self.mutableBlockOrder.length - 1; i >= 0; i--) { + if (self.blockOrder.get(i) === op.sourceBlockId) { + self.mutableBlockOrder.delete(i, 1); + break; + } +} + +return [op.targetBlockId, op.sourceBlockId]; +} diff --git a/packages/core/src/editor/applyInlineAndMetaOps.ts b/packages/core/src/editor/applyInlineAndMetaOps.ts new file mode 100644 index 0000000..71f4dac --- /dev/null +++ b/packages/core/src/editor/applyInlineAndMetaOps.ts @@ -0,0 +1,310 @@ +import type { + DocumentOp, + OpOrigin, + InsertBlockOp, + UpdateBlockOp, + DeleteBlockOp, + MoveBlockOp, + ConvertBlockOp, + SplitBlockOp, + MergeBlocksOp, + InsertTextOp, + DeleteTextOp, + FormatTextOp, + ReplaceTextOp, + InsertInlineNodeOp, + RemoveInlineNodeOp, + UpdateLayoutOp, + SetMetaOp, + CreateAppOp, + UpdateAppOp, + DeleteAppOp, + SetSelectionOp, + UpdateTableColumnsOp, + CRDTArray, +} from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; +import { resolveRuntimeContentType } from "../schema/contentType"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, + getStringProp, + getTableColumns, + getTableContent, + isCRDTMap, +} from "./crdtShapes"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} +const ZERO_WIDTH_SPACE = "\u200B"; + + +export function insertText(pipeline: ApplyPipeline, op: InsertTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { + content.delete(0, 1); +} + +const marks = op.marks ? self._resolveMarks(op.marks) : undefined; +content.insert(op.offset, op.text, marks); +return [op.blockId]; +} + +export function deleteText(pipeline: ApplyPipeline, op: DeleteTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +content.delete(op.offset, op.length); +return [op.blockId]; +} + +export function formatText(pipeline: ApplyPipeline, op: FormatTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +content.format(op.offset, op.length, op.marks); +return [op.blockId]; +} + +export function replaceText(pipeline: ApplyPipeline, op: ReplaceTextOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +if (content.length === 1 && content.toString() === ZERO_WIDTH_SPACE) { + content.delete(0, 1); +} + +content.delete(op.offset, op.length); +const marks = op.marks ? self._resolveMarks(op.marks) : undefined; +content.insert(op.offset, op.text, marks); +return [op.blockId]; +} + +export function resolveMarks(pipeline: ApplyPipeline, + marks: Record, +): Record { + const self = pipeline as ApplyPipelineRuntime; +const resolved: Record = {}; +for (const [type, value] of Object.entries(marks)) { + const schema = self._registry.resolveInline(type); + if (!schema) continue; + resolved[type] = value; +} +return resolved; +} + +export function insertInlineNode(pipeline: ApplyPipeline, op: InsertInlineNodeOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getInlineTextContent(blockMap); +if (!content) return []; + +content.insertEmbed(op.offset, { + type: op.nodeType, + ...op.props, +}); +return [op.blockId]; +} + +export function removeInlineNode(pipeline: ApplyPipeline, op: RemoveInlineNodeOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; +const content = self._getTextContent(blockMap); +if (!content) return []; + +content.delete(op.offset, 1); +return [op.blockId]; +} + +export function setSelectionOp(pipeline: ApplyPipeline, op: SetSelectionOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self._selection.setSelection(op.selection); +return []; +} + +export function updateLayout(pipeline: ApplyPipeline, op: UpdateLayoutOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const layoutMap = self._getOrCreateMapProp(blockMap, "layout"); + +for (const [key, value] of Object.entries(op.layout)) { + if (value === undefined || value === null) { + layoutMap.delete(key); + } else { + layoutMap.set(key, value); + } +} + +return [op.blockId]; +} + +export function createApp(pipeline: ApplyPipeline, op: CreateAppOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const appMap = self._createMutableMap(); +appMap.set("type", op.appType); +appMap.set("placement", op.placement); + +if (op.config && Object.keys(op.config).length > 0) { + const configMap = self._createMutableMap(); + for (const [key, value] of Object.entries(op.config)) { + configMap.set(key, value); + } + appMap.set("config", configMap); +} + +self.mutableApps.set(op.appId, appMap); +return []; +} + +export function updateApp(pipeline: ApplyPipeline, op: UpdateAppOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const appMap = self._getMutableAppMap(op.appId); +if (!appMap) return []; + +const configMap = self._getOrCreateMapProp(appMap, "config"); + +for (const [key, value] of Object.entries(op.patch)) { + if (value === undefined || value === null) { + configMap.delete(key); + } else { + configMap.set(key, value); + } +} +return []; +} + +export function deleteApp(pipeline: ApplyPipeline, op: DeleteAppOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +self.mutableApps.delete(op.appId); +return []; +} + +export function tableOp(pipeline: ApplyPipeline, op: DocumentOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const tableOp = op as { blockId: string; type: string }; +const blockMap = self._getMutableBlockMap(tableOp.blockId); +if (!blockMap) return []; + +const blockType = blockMap.get("type"); +if (blockType === "database") { + if (op.type === "update-table-columns") { + return self._databaseOps.replaceColumns( + blockMap, + (op as UpdateTableColumnsOp).columns, + ) + ? [tableOp.blockId] + : []; + } + + if (self._isDatabaseStructuralTableOp(op.type)) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_006", + level: "warn", + source: "apply", + message: `apply: skipping ${op.type} for database block "${tableOp.blockId}"`, + remediation: + "Use database operations for structural database changes so row ids, column schema, and views stay in sync.", + op, + }); + return []; + } +} + +return self._tableGrid.execute(blockMap, op); +} + +export function databaseOp(pipeline: ApplyPipeline, op: DocumentOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const databaseOp = op as { type: string; blockId: string }; +const blockMap = self._getMutableBlockMap(databaseOp.blockId); +if (!blockMap) return []; + +return self._databaseOps.execute(blockMap, op); +} + +export function clearTableState(pipeline: ApplyPipeline, blockMap: MutableMap): void { + const self = pipeline as ApplyPipelineRuntime; +blockMap.delete("tableContent"); +blockMap.delete("tableColumns"); +} + +export function clearDatabaseState(pipeline: ApplyPipeline, blockMap: MutableMap): void { + const self = pipeline as ApplyPipelineRuntime; +blockMap.delete("databaseViews"); +blockMap.delete("databasePrimaryViewId"); +} + +export function isDatabaseStructuralTableOp(pipeline: ApplyPipeline, type: string): boolean { + const self = pipeline as ApplyPipelineRuntime; +return ( + type === "insert-table-row" || + type === "delete-table-row" || + type === "insert-table-column" || + type === "delete-table-column" || + type === "merge-table-cells" || + type === "split-table-cell" +); +} + +export function getPreservedInlineDeltas( + _pipeline: ApplyPipeline, + content: CRDTText | undefined, +): Array<{ insert: string; attributes?: Record }> { + if (!content || typeof content.toDelta !== "function") { + return []; + } + return content.toDelta().filter( + (delta): delta is { insert: string; attributes?: Record } => + typeof delta.insert === "string" && delta.insert !== ZERO_WIDTH_SPACE, + ); +} + +export function setMeta(pipeline: ApplyPipeline, op: SetMetaOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +const blockMap = self._getMutableBlockMap(op.blockId); +if (!blockMap) return []; + +const metaMap = self._getOrCreateMapProp(blockMap, "meta"); + +// Persist metadata as plain JSON so adapters can round-trip it predictably. +if (op.data === null) { + metaMap.delete(op.namespace); +} else { + metaMap.set(op.namespace, op.data); +} + +return [op.blockId]; +} diff --git a/packages/core/src/editor/applyPipelineRunner.ts b/packages/core/src/editor/applyPipelineRunner.ts new file mode 100644 index 0000000..3f8e9c1 --- /dev/null +++ b/packages/core/src/editor/applyPipelineRunner.ts @@ -0,0 +1,384 @@ +import type { + DocumentOp, + OpOrigin, + CRDTEvent, + InsertBlockOp, + UpdateBlockOp, + DeleteBlockOp, + MoveBlockOp, + ConvertBlockOp, + SplitBlockOp, + MergeBlocksOp, + InsertTextOp, + DeleteTextOp, + FormatTextOp, + ReplaceTextOp, + InsertInlineNodeOp, + RemoveInlineNodeOp, + UpdateLayoutOp, + SetMetaOp, + CreateAppOp, + UpdateAppOp, + DeleteAppOp, + SetSelectionOp, + UpdateTableColumnsOp, + CRDTArray, +} from "@pen/types"; +import { generateId, getOpOriginType } from "@pen/types"; +import { resolveRuntimeContentType } from "../schema/contentType"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, + getStringProp, + getTableColumns, + getTableContent, + isCRDTMap, +} from "./crdtShapes"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} +const ZERO_WIDTH_SPACE = "\u200B"; + + +export function applyInternal(pipeline: ApplyPipeline, ops: DocumentOp[], origin: OpOrigin): void { + const self = pipeline as ApplyPipelineRuntime; +if (self._applying) { + self._queue.push({ ops, origin }); + return; +} + +self._applying = true; +try { + self._executeOps(ops, origin); + while (self._queue.length > 0) { + const { ops: queued, origin: queuedOrigin } = + self._queue.shift()!; + self._executeOps(queued, queuedOrigin); + } +} finally { + self._applying = false; +} +} + +export function executeOps(pipeline: ApplyPipeline, ops: DocumentOp[], origin: OpOrigin): void { + const self = pipeline as ApplyPipelineRuntime; +// Let extensions transform ops before validation and execution. +let transformedOps = ops; +for (const { hook } of self._beforeApplyHooks) { + try { + transformedOps = hook(transformedOps, { origin }); + } catch (err) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_005", + level: "error", + source: "apply", + message: "onBeforeApply hook threw", + remediation: + "Update the onBeforeApply hook to handle incoming ops defensively and " + + "always return a valid DocumentOp array.", + error: err, + }); + } +} +if (self._finalBeforeApplyHook) { + try { + transformedOps = self._finalBeforeApplyHook(transformedOps, { + origin, + }); + } catch (err) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_007", + level: "error", + source: "apply", + message: "final apply boundary hook threw", + remediation: + "Update the final apply boundary hook to handle incoming ops defensively and " + + "always return a valid DocumentOp array.", + error: err, + }); + } +} + +self._emitApplyBoundary({ + phase: "before", + ops: transformedOps, + origin, + applied: false, +}); + +const affectedBlocks: string[] = []; +const validatedOps: DocumentOp[] = []; +const pendingBlockIds = new Set(); + +for (const op of transformedOps) { + const blockId = self._opBlockId(op); + + if (!self._validateOp(op)) continue; + + if (op.type === "insert-block") { + pendingBlockIds.add(op.blockId); + } + + if ( + blockId && + !self._blockExists(blockId) && + !pendingBlockIds.has(blockId) && + op.type !== "insert-block" + ) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_003", + level: "warn", + source: "apply", + message: `apply: skipping ${op.type} for non-existent block "${blockId}"`, + }); + continue; + } + + validatedOps.push(op); +} + +if (validatedOps.length === 0) { + self._emitApplyBoundary({ + phase: "after", + ops: transformedOps, + origin, + applied: false, + }); + return; +} + +self._suppressObserver = true; + +try { + self._adapter.transact( + self._crdtDoc, + () => { + for (const op of validatedOps) { + const affected = self._executeSingleOp(op); + affectedBlocks.push(...affected); + } + + for (const blockId of affectedBlocks) { + self._engine.markDirty(blockId); + } + + self._engine.normalizeDirty(); + }, + getOpOriginType(origin), + ); +} finally { + self._suppressObserver = false; +} + +const event: CRDTEvent = { + origin, + affectedBlocks: [...new Set(affectedBlocks)], + ops: validatedOps, + timestamp: Date.now(), +}; + +self._onDidApply?.(event); +self._emitApplyBoundary({ + phase: "after", + ops: validatedOps, + origin, + applied: true, +}); +} + +export function emitApplyBoundary(pipeline: ApplyPipeline, event: { + phase: "before" | "after"; + ops: readonly DocumentOp[]; + origin: OpOrigin; + applied: boolean; +}): void { + const self = pipeline as ApplyPipelineRuntime; + for (const hook of self._applyBoundaryHooks) { + try { + hook(event); + } catch (err) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_008", + level: "error", + source: "apply", + message: "apply boundary hook threw", + remediation: + "Update the apply boundary hook to avoid throwing during transaction lifecycle notifications.", + error: err, + }); + } + } +} + +export function validateOp(pipeline: ApplyPipeline, op: DocumentOp): boolean { + const self = pipeline as ApplyPipelineRuntime; +switch (op.type) { + case "insert-block": { + const schema = self._registry.resolve(op.blockType); + if (!schema) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + message: `Unknown block type: "${op.blockType}"`, + op, + }); + return false; + } + return true; + } + case "convert-block": { + const schema = self._registry.resolve(op.newType); + if (!schema) { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + message: `Unknown block type: "${op.newType}"`, + op, + }); + return false; + } + return true; + } + case "insert-inline-node": { + const schema = self._registry.resolveInline(op.nodeType); + if (!schema || schema.kind !== "node") { + self._emitter.emit("diagnostic", { + code: "PEN_APPLY_002", + level: "warn", + source: "apply", + message: `Unknown inline node type: "${op.nodeType}"`, + op, + }); + return false; + } + return true; + } + default: + return true; +} +} + +export function resolvePosition(pipeline: ApplyPipeline, position: import("@pen/types").Position): number { + const self = pipeline as ApplyPipelineRuntime; +const blockOrder = self._doc.blockOrder; + +if (position === "first") return 0; +if (position === "last") return blockOrder.length; + +if (typeof position === "object" && "after" in position) { + for (let i = 0; i < blockOrder.length; i++) { + if ((blockOrder.get(i) as string) === position.after) + return i + 1; + } + return blockOrder.length; +} + +if (typeof position === "object" && "before" in position) { + for (let i = 0; i < blockOrder.length; i++) { + if ((blockOrder.get(i) as string) === position.before) return i; + } + return 0; +} + +if (typeof position === "object" && "parent" in position) { + const parentMap = self.blocks.get(position.parent); + if (!parentMap) return blockOrder.length; + const children = parentMap.get("children") as + | CRDTArray + | undefined; + if (!children) return 0; + return Math.min(position.index, children.length); +} + +return blockOrder.length; +} + +export function executeSingleOp(pipeline: ApplyPipeline, op: DocumentOp): string[] { + const self = pipeline as ApplyPipelineRuntime; +switch (op.type) { + case "insert-block": + return self._insertBlock(op); + case "update-block": + return self._updateBlock(op); + case "delete-block": + return self._deleteBlock(op); + case "move-block": + return self._moveBlock(op); + case "convert-block": + return self._convertBlock(op); + case "split-block": + return self._splitBlock(op); + case "merge-blocks": + return self._mergeBlocks(op); + case "insert-text": + return self._insertText(op); + case "delete-text": + return self._deleteText(op); + case "format-text": + return self._formatText(op); + case "replace-text": + return self._replaceText(op); + case "insert-inline-node": + return self._insertInlineNode(op); + case "remove-inline-node": + return self._removeInlineNode(op); + case "set-selection": + return self._setSelection(op); + case "update-layout": + return self._updateLayout(op); + case "create-app": + return self._createApp(op); + case "update-app": + return self._updateApp(op); + case "delete-app": + return self._deleteApp(op); + case "insert-table-row": + case "delete-table-row": + case "insert-table-column": + case "delete-table-column": + case "merge-table-cells": + case "split-table-cell": + case "insert-table-cell-text": + case "delete-table-cell-text": + case "format-table-cell-text": + case "update-table-columns": + return self._tableOp(op); + case "database-add-column": + case "database-update-column": + case "database-convert-column": + case "database-remove-column": + case "database-insert-row": + case "database-update-cell": + case "database-delete-row": + case "database-delete-rows": + case "database-duplicate-row": + case "database-move-row": + case "database-add-view": + case "database-update-view": + case "database-remove-view": + case "database-set-active-view": + case "database-update-select-options": + return self._databaseOp(op); + case "set-meta": + return self._setMeta(op); + default: + return []; +} +} diff --git a/packages/core/src/editor/applySharedHelpers.ts b/packages/core/src/editor/applySharedHelpers.ts new file mode 100644 index 0000000..78f46f6 --- /dev/null +++ b/packages/core/src/editor/applySharedHelpers.ts @@ -0,0 +1,149 @@ +import type { CRDTDocument, CRDTArray, CRDTMap, DocumentOp } from "@pen/types"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + getArrayProp, + getMapProp, +} from "./crdtShapes"; +import type { SchemaEngineImpl } from "../schema/normalize"; +import type { ApplyPipeline } from "./apply"; + +type ApplyPipelineRuntime = any; +type MutableMap = CRDTUnknownMap & { delete(key: string): void }; +type MutableStringArray = CRDTUnknownArray; +interface CRDTInlineText extends CRDTText { + insertEmbed(offset: number, value: Record): void; +} +interface CRDTText { + insert(offset: number, text: string, attributes?: Record): void; + delete(offset: number, length: number): void; + format(offset: number, length: number, attributes: Record): void; + toDelta(): Array<{ insert: string | object; attributes?: Record }>; + toString(): string; + readonly length: number; +} + + +export function blockExists(pipeline: ApplyPipeline, blockId: string): boolean { + const self = pipeline as ApplyPipelineRuntime; +return self.blocks.has(blockId); +} + +export function createMutableMap(pipeline: ApplyPipeline, ): MutableMap { + const self = pipeline as ApplyPipelineRuntime; +return self._adapter.createMap() as MutableMap; +} + +export function getMutableBlockMap(pipeline: ApplyPipeline, blockId: string): MutableMap | null { + const self = pipeline as ApplyPipelineRuntime; +return ( + (self.blocks.get(blockId) as unknown as MutableMap | undefined) ?? + null +); +} + +export function getMutableAppMap(pipeline: ApplyPipeline, appId: string): MutableMap | null { + const self = pipeline as ApplyPipelineRuntime; +return ( + (self.apps.get(appId) as unknown as MutableMap | undefined) ?? null +); +} + +export function getOrCreateMapProp(pipeline: ApplyPipeline, + container: CRDTUnknownMap, + key: string, +): MutableMap { + const self = pipeline as ApplyPipelineRuntime; +const existing = getMapProp(container, key); +if (existing) { + return existing as MutableMap; +} +const map = self._createMutableMap(); +container.set(key, map); +return map; +} + +export function getOrCreateStringArrayProp(pipeline: ApplyPipeline, + container: CRDTUnknownMap, + key: string, +): MutableStringArray { + const self = pipeline as ApplyPipelineRuntime; +const existing = getArrayProp(container, key); +if (existing) { + return existing as MutableStringArray; +} +const array = self._adapter.createArray() as MutableStringArray; +container.set(key, array); +return array; +} + +export function removeBlockIdFromArray(pipeline: ApplyPipeline, + array: MutableStringArray, + blockId: string, + stopAfterFirst = false, +): void { + const self = pipeline as ApplyPipelineRuntime; +for (let index = array.length - 1; index >= 0; index--) { + if (array.get(index) !== blockId) { + continue; + } + array.delete(index, 1); + if (stopAfterFirst) { + return; + } +} +} + +export function removeBlockIdFromAllChildren(pipeline: ApplyPipeline, blockId: string): void { + const self = pipeline as ApplyPipelineRuntime; +for (const [, parentMap] of self.blocks.entries()) { + const children = getArrayProp( + parentMap as unknown as CRDTUnknownMap, + "children", + ); + if (!children) { + continue; + } + self._removeBlockIdFromArray( + children as MutableStringArray, + blockId, + ); +} +} + +export function getTextContent(pipeline: ApplyPipeline, blockMap: CRDTUnknownMap): CRDTText | undefined { + const self = pipeline as ApplyPipelineRuntime; +const content = blockMap.get("content"); +return content && + typeof content === "object" && + typeof (content as { insert?: unknown }).insert === "function" && + typeof (content as { delete?: unknown }).delete === "function" && + typeof (content as { format?: unknown }).format === "function" && + typeof (content as { toDelta?: unknown }).toDelta === "function" && + typeof (content as { toString?: unknown }).toString === + "function" && + typeof (content as { length?: unknown }).length === "number" + ? (content as CRDTText) + : undefined; +} + +export function getInlineTextContent(pipeline: ApplyPipeline, + blockMap: CRDTUnknownMap, +): CRDTInlineText | undefined { + const self = pipeline as ApplyPipelineRuntime; +const content = self._getTextContent(blockMap); +return content && + typeof (content as { insertEmbed?: unknown }).insertEmbed === + "function" + ? (content as CRDTInlineText) + : undefined; +} + +export function opBlockId(pipeline: ApplyPipeline, op: DocumentOp): string | null { + const self = pipeline as ApplyPipelineRuntime; +if ("blockId" in op) return (op as { blockId: string }).blockId; +if ("targetBlockId" in op) + return (op as { targetBlockId: string }).targetBlockId; +if ("appId" in op) return null; +return null; +} diff --git a/packages/core/src/editor/databaseViewExecutor.ts b/packages/core/src/editor/databaseViewExecutor.ts index 325195a..e22d446 100644 --- a/packages/core/src/editor/databaseViewExecutor.ts +++ b/packages/core/src/editor/databaseViewExecutor.ts @@ -4,10 +4,6 @@ import type { DatabaseRowPinning, DatabaseRemoveViewOp, DatabaseSetActiveViewOp, - DatabaseSort, - DatabaseViewState, - FilterCondition, - FilterGroup, DatabaseUpdateViewOp, } from "@pen/types"; import { generateId } from "@pen/types"; @@ -23,14 +19,17 @@ import { isCRDTArray, isCRDTMap, } from "./crdtShapes"; +import { DatabaseViewHelpers } from "./databaseViewHelpers"; import { TableGridExecutor } from "./tableGridExecutor"; export class DatabaseViewExecutor { private readonly _adapter: CRDTAdapter; + private readonly _helpers: DatabaseViewHelpers; private readonly _tableGrid: TableGridExecutor; constructor(adapter: CRDTAdapter, tableGrid: TableGridExecutor) { this._adapter = adapter; + this._helpers = new DatabaseViewHelpers(adapter, tableGrid); this._tableGrid = tableGrid; } @@ -87,8 +86,8 @@ export class DatabaseViewExecutor { continue; } - this._replaceViewStringArray(viewMap, "columnOrder", columnIds); - this._replaceViewStringArray(viewMap, "visibleColumnIds", columnIds); + this._helpers.replaceViewStringArray(viewMap, "columnOrder", columnIds); + this._helpers.replaceViewStringArray(viewMap, "visibleColumnIds", columnIds); const sort = viewMap.get("sort"); if (isCRDTArray(sort)) { @@ -124,8 +123,8 @@ export class DatabaseViewExecutor { ), ); databaseViews.insert(nextIndex, [ - this._createDatabaseViewMap( - this._normalizeViewState(blockMap, op.view), + this._helpers.createDatabaseViewMap( + this._helpers.normalizeViewState(blockMap, op.view), ), ]); if (!blockMap.get("databasePrimaryViewId")) { @@ -154,7 +153,7 @@ export class DatabaseViewExecutor { continue; } - const normalizedPatch = this._normalizeViewPatch(blockMap, op.patch); + const normalizedPatch = this._helpers.normalizeViewPatch(blockMap, op.patch); for (const [key, value] of Object.entries(normalizedPatch)) { if (value === undefined) { continue; @@ -182,7 +181,7 @@ export class DatabaseViewExecutor { if (value.length > 0) { const sortEntries = value.flatMap((entry) => entry != null && typeof entry === "object" - ? [this._createRecordMap(entry)] + ? [this._helpers.createRecordMap(entry)] : [], ); array.insert(0, sortEntries); @@ -200,7 +199,7 @@ export class DatabaseViewExecutor { if (!rowPinning.top?.length && !rowPinning.bottom?.length) { viewMap.delete?.(key); } else { - viewMap.set(key, this._createNestedRecord(value)); + viewMap.set(key, this._helpers.createNestedRecord(value)); } continue; } @@ -210,7 +209,7 @@ export class DatabaseViewExecutor { typeof value === "object" && !Array.isArray(value) ) { - viewMap.set(key, this._createNestedRecord(value)); + viewMap.set(key, this._helpers.createNestedRecord(value)); continue; } viewMap.set(key, value); @@ -257,7 +256,7 @@ export class DatabaseViewExecutor { setActiveView(blockMap: CRDTUnknownMap, op: DatabaseSetActiveViewOp): boolean { const databaseViews = getDatabaseViews(blockMap); - const targetViewMap = this._findDatabaseViewMap(databaseViews, op.viewId); + const targetViewMap = this._helpers.findDatabaseViewMap(databaseViews, op.viewId); if (!targetViewMap) { return false; } @@ -286,13 +285,13 @@ export class DatabaseViewExecutor { if (targetViewId && currentViewId !== targetViewId) { continue; } - this._insertStringIntoViewArray( + this._helpers.insertStringIntoViewArray( viewMap, "columnOrder", columnId, columnIndex, ); - this._insertStringIntoViewArray( + this._helpers.insertStringIntoViewArray( viewMap, "visibleColumnIds", columnId, @@ -314,8 +313,8 @@ export class DatabaseViewExecutor { if (!viewMap || !isCRDTMap(viewMap)) { continue; } - this._removeStringFromViewArray(viewMap, "columnOrder", columnId); - this._removeStringFromViewArray( + this._helpers.removeStringFromViewArray(viewMap, "columnOrder", columnId); + this._helpers.removeStringFromViewArray( viewMap, "visibleColumnIds", columnId, @@ -353,362 +352,8 @@ export class DatabaseViewExecutor { if (!rowPinning || !isCRDTMap(rowPinning)) { continue; } - this._removeStringsFromNestedArray(rowPinning, "top", rowIds); - this._removeStringsFromNestedArray(rowPinning, "bottom", rowIds); + this._helpers.removeStringsFromNestedArray(rowPinning, "top", rowIds); + this._helpers.removeStringsFromNestedArray(rowPinning, "bottom", rowIds); } } - - private _normalizeViewState( - blockMap: CRDTUnknownMap, - view: DatabaseViewState, - ): DatabaseViewState { - return { - ...view, - ...this._normalizeViewPatch(blockMap, view), - }; - } - - private _normalizeViewPatch( - blockMap: CRDTUnknownMap, - patch: Partial, - ): Partial { - const columnIds = this._readColumnIds(blockMap); - const columnIdSet = new Set(columnIds); - const rowIdSet = new Set(this._readRowIds(blockMap)); - const normalized: Partial = { ...patch }; - - if (patch.visibleColumnIds) { - normalized.visibleColumnIds = this._normalizeColumnIdList( - patch.visibleColumnIds, - columnIdSet, - ); - } - - if (patch.columnOrder) { - normalized.columnOrder = this._normalizeColumnIdList( - patch.columnOrder, - columnIdSet, - ); - } - - if (patch.sort) { - normalized.sort = this._normalizeSort(patch.sort, columnIdSet); - } - - if (patch.groupBy !== undefined && patch.groupBy !== null) { - normalized.groupBy = columnIdSet.has(patch.groupBy) - ? patch.groupBy - : null; - } - - if (patch.rowPinning) { - normalized.rowPinning = this._normalizeRowPinning( - patch.rowPinning, - rowIdSet, - ); - } - - if (patch.filter) { - normalized.filter = this._normalizeFilterGroup( - patch.filter, - columnIdSet, - ); - } - - return normalized; - } - - private _readColumnIds(blockMap: CRDTUnknownMap): string[] { - return this._tableGrid.readColumnIds(getTableColumns(blockMap)); - } - - private _readRowIds(blockMap: CRDTUnknownMap): string[] { - const tableContent = getTableContent(blockMap); - if (!tableContent) { - return []; - } - - const rowIds: string[] = []; - for (let index = 0; index < tableContent.length; index++) { - const row = tableContent.get(index); - if (!row || !isCRDTMap(row)) { - continue; - } - const rowId = getStringProp(row, "id"); - if (rowId) { - rowIds.push(rowId); - } - } - return rowIds; - } - - private _normalizeColumnIdList( - values: string[], - columnIdSet: Set, - ): string[] { - const seen = new Set(); - return values.filter((value) => { - if (!columnIdSet.has(value) || seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); - } - - private _normalizeSort( - sorts: DatabaseSort[], - columnIdSet: Set, - ): DatabaseSort[] { - const seen = new Set(); - return sorts.filter((sort) => { - if ( - !columnIdSet.has(sort.columnId) || - (sort.direction !== "asc" && sort.direction !== "desc") || - seen.has(sort.columnId) - ) { - return false; - } - seen.add(sort.columnId); - return true; - }); - } - - private _normalizeRowPinning( - rowPinning: DatabaseRowPinning, - rowIdSet: Set, - ): DatabaseRowPinning { - const top = this._normalizeUniqueIds(rowPinning.top ?? [], rowIdSet); - const bottom = this._normalizeUniqueIds( - (rowPinning.bottom ?? []).filter((rowId) => !top.includes(rowId)), - rowIdSet, - ); - return { - ...(top.length > 0 ? { top } : {}), - ...(bottom.length > 0 ? { bottom } : {}), - }; - } - - private _normalizeUniqueIds(values: string[], allowed: Set): string[] { - const seen = new Set(); - return values.filter((value) => { - if (!allowed.has(value) || seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); - } - - private _normalizeFilterGroup( - group: FilterGroup, - columnIdSet: Set, - ): FilterGroup | null { - const conditions: Array = - group.conditions.flatMap((condition) => { - if (this._isFilterGroup(condition)) { - const nestedGroup = this._normalizeFilterGroup(condition, columnIdSet); - return nestedGroup ? [nestedGroup] : []; - } - return this._isValidFilterCondition(condition, columnIdSet) - ? [condition] - : []; - }); - - if (conditions.length === 0) { - return null; - } - - return { - operator: group.operator === "or" ? "or" : "and", - conditions, - }; - } - - private _isFilterGroup( - value: FilterCondition | FilterGroup, - ): value is FilterGroup { - return Array.isArray((value as FilterGroup).conditions); - } - - private _isValidFilterCondition( - condition: FilterCondition, - columnIdSet: Set, - ): boolean { - return columnIdSet.has(condition.columnId); - } - - private _findDatabaseViewMap( - databaseViews: ReturnType, - viewId: string, - ): DatabaseViewMap | null { - if (!databaseViews) { - return null; - } - for (let index = 0; index < databaseViews.length; index++) { - const viewMap = databaseViews.get(index); - if (viewMap && isCRDTMap(viewMap) && getStringProp(viewMap, "id") === viewId) { - return viewMap; - } - } - return null; - } - - private _insertStringIntoViewArray( - viewMap: CRDTUnknownMap, - key: "columnOrder" | "visibleColumnIds", - value: string, - index: number, - ): void { - let arrayValue = viewMap.get(key); - if (!isCRDTArray(arrayValue)) { - arrayValue = this._adapter.createArray() as CRDTUnknownArray; - viewMap.set(key, arrayValue); - } - const array = arrayValue as CRDTUnknownArray; - for (let currentIndex = 0; currentIndex < array.length; currentIndex++) { - if (array.get(currentIndex) === value) { - array.delete(currentIndex, 1); - break; - } - } - array.insert(Math.max(0, Math.min(index, array.length)), [value]); - } - - private _removeStringFromViewArray( - viewMap: CRDTUnknownMap, - key: "columnOrder" | "visibleColumnIds", - value: string, - ): void { - const arrayValue = viewMap.get(key); - if (!isCRDTArray(arrayValue)) { - return; - } - const array = arrayValue as CRDTUnknownArray; - for (let index = array.length - 1; index >= 0; index--) { - if (array.get(index) === value) { - array.delete(index, 1); - } - } - } - - private _replaceViewStringArray( - viewMap: CRDTUnknownMap, - key: "columnOrder" | "visibleColumnIds", - values: string[], - ): void { - const array = this._adapter.createArray() as CRDTUnknownArray; - if (values.length > 0) { - array.insert(0, values); - } - viewMap.set(key, array); - } - - private _removeStringsFromNestedArray( - map: CRDTUnknownMap, - key: "top" | "bottom", - values: string[], - ): void { - const arrayValue = map.get(key); - if (!isCRDTArray(arrayValue)) { - return; - } - const array = arrayValue as CRDTUnknownArray; - const valueSet = new Set(values); - for (let index = array.length - 1; index >= 0; index--) { - if (valueSet.has(array.get(index))) { - array.delete(index, 1); - } - } - } - - private _createDatabaseViewMap(view: DatabaseViewState): DatabaseViewMap { - const viewMap = this._adapter.createMap() as DatabaseViewMap; - viewMap.set("id", view.id); - viewMap.set("type", view.type); - if (view.title) { - viewMap.set("title", view.title); - } - if (view.visibleColumnIds) { - const visibleColumnIds = this._adapter.createArray() as CRDTUnknownArray; - if (view.visibleColumnIds.length > 0) { - visibleColumnIds.insert(0, view.visibleColumnIds); - } - viewMap.set("visibleColumnIds", visibleColumnIds); - } - if (view.columnOrder) { - const columnOrder = this._adapter.createArray() as CRDTUnknownArray; - if (view.columnOrder.length > 0) { - columnOrder.insert(0, view.columnOrder); - } - viewMap.set("columnOrder", columnOrder); - } - if (view.sort) { - const sort = this._adapter.createArray() as CRDTUnknownArray; - if (view.sort.length > 0) { - sort.insert(0, view.sort.map((entry) => this._createRecordMap(entry))); - } - viewMap.set("sort", sort); - } - if (view.filter) { - viewMap.set("filter", this._createNestedRecord(view.filter)); - } - if (view.groupBy !== undefined) { - if (view.groupBy === null) { - viewMap.set("groupBy", null); - } else { - viewMap.set("groupBy", view.groupBy); - } - } - if (view.rowPinning) { - viewMap.set("rowPinning", this._createNestedRecord(view.rowPinning)); - } - if (view.pageIndex !== undefined) { - viewMap.set("pageIndex", view.pageIndex); - } - if (view.pageSize !== undefined) { - viewMap.set("pageSize", view.pageSize); - } - return viewMap; - } - - private _createRecordMap(record: object): DatabaseViewMap { - const map = this._adapter.createMap() as DatabaseViewMap; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) { - map.set(key, value); - } - } - return map; - } - - private _createNestedRecord(record: object): DatabaseViewMap { - const map = this._adapter.createMap() as DatabaseViewMap; - for (const [key, value] of Object.entries(record)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - const array = this._adapter.createArray() as CRDTUnknownArray; - if (value.length > 0) { - array.insert( - 0, - value.map((entry) => - entry && typeof entry === "object" - ? this._createNestedRecord(entry) - : entry, - ), - ); - } - map.set(key, array); - continue; - } - if (value && typeof value === "object") { - map.set(key, this._createNestedRecord(value)); - continue; - } - map.set(key, value); - } - return map; - } } diff --git a/packages/core/src/editor/databaseViewHelpers.ts b/packages/core/src/editor/databaseViewHelpers.ts new file mode 100644 index 0000000..4d2b3ef --- /dev/null +++ b/packages/core/src/editor/databaseViewHelpers.ts @@ -0,0 +1,381 @@ +import type { + CRDTAdapter, + DatabaseRowPinning, + DatabaseSort, + DatabaseViewState, + FilterCondition, + FilterGroup, +} from "@pen/types"; +import { + type CRDTUnknownArray, + type CRDTUnknownMap, + type DatabaseViewMap, + getDatabaseViews, + getStringProp, + getTableColumns, + getTableContent, + isCRDTArray, + isCRDTMap, +} from "./crdtShapes"; +import type { TableGridExecutor } from "./tableGridExecutor"; + +export class DatabaseViewHelpers { + constructor( + private readonly _adapter: CRDTAdapter, + private readonly _tableGrid: TableGridExecutor, + ) {} + + normalizeViewState( + blockMap: CRDTUnknownMap, + view: DatabaseViewState, + ): DatabaseViewState { + return { + ...view, + ...this.normalizeViewPatch(blockMap, view), + }; + } + + normalizeViewPatch( + blockMap: CRDTUnknownMap, + patch: Partial, + ): Partial { + const columnIds = this._readColumnIds(blockMap); + const columnIdSet = new Set(columnIds); + const rowIdSet = new Set(this._readRowIds(blockMap)); + const normalized: Partial = { ...patch }; + + if (patch.visibleColumnIds) { + normalized.visibleColumnIds = this._normalizeColumnIdList( + patch.visibleColumnIds, + columnIdSet, + ); + } + + if (patch.columnOrder) { + normalized.columnOrder = this._normalizeColumnIdList( + patch.columnOrder, + columnIdSet, + ); + } + + if (patch.sort) { + normalized.sort = this._normalizeSort(patch.sort, columnIdSet); + } + + if (patch.groupBy !== undefined && patch.groupBy !== null) { + normalized.groupBy = columnIdSet.has(patch.groupBy) + ? patch.groupBy + : null; + } + + if (patch.rowPinning) { + normalized.rowPinning = this._normalizeRowPinning( + patch.rowPinning, + rowIdSet, + ); + } + + if (patch.filter) { + normalized.filter = this._normalizeFilterGroup( + patch.filter, + columnIdSet, + ); + } + + return normalized; + } + + findDatabaseViewMap( + databaseViews: ReturnType, + viewId: string, + ): DatabaseViewMap | null { + if (!databaseViews) { + return null; + } + for (let index = 0; index < databaseViews.length; index++) { + const viewMap = databaseViews.get(index); + if (viewMap && isCRDTMap(viewMap) && getStringProp(viewMap, "id") === viewId) { + return viewMap; + } + } + return null; + } + + insertStringIntoViewArray( + viewMap: CRDTUnknownMap, + key: "columnOrder" | "visibleColumnIds", + value: string, + index: number, + ): void { + let arrayValue = viewMap.get(key); + if (!isCRDTArray(arrayValue)) { + arrayValue = this._adapter.createArray() as CRDTUnknownArray; + viewMap.set(key, arrayValue); + } + const array = arrayValue as CRDTUnknownArray; + for (let currentIndex = 0; currentIndex < array.length; currentIndex++) { + if (array.get(currentIndex) === value) { + array.delete(currentIndex, 1); + break; + } + } + array.insert(Math.max(0, Math.min(index, array.length)), [value]); + } + + removeStringFromViewArray( + viewMap: CRDTUnknownMap, + key: "columnOrder" | "visibleColumnIds", + value: string, + ): void { + const arrayValue = viewMap.get(key); + if (!isCRDTArray(arrayValue)) { + return; + } + const array = arrayValue as CRDTUnknownArray; + for (let index = array.length - 1; index >= 0; index--) { + if (array.get(index) === value) { + array.delete(index, 1); + } + } + } + + replaceViewStringArray( + viewMap: CRDTUnknownMap, + key: "columnOrder" | "visibleColumnIds", + values: string[], + ): void { + const array = this._adapter.createArray() as CRDTUnknownArray; + if (values.length > 0) { + array.insert(0, values); + } + viewMap.set(key, array); + } + + removeStringsFromNestedArray( + map: CRDTUnknownMap, + key: "top" | "bottom", + values: string[], + ): void { + const arrayValue = map.get(key); + if (!isCRDTArray(arrayValue)) { + return; + } + const array = arrayValue as CRDTUnknownArray; + const valueSet = new Set(values); + for (let index = array.length - 1; index >= 0; index--) { + if (valueSet.has(array.get(index))) { + array.delete(index, 1); + } + } + } + + createDatabaseViewMap(view: DatabaseViewState): DatabaseViewMap { + const viewMap = this._adapter.createMap() as DatabaseViewMap; + viewMap.set("id", view.id); + viewMap.set("type", view.type); + if (view.title) { + viewMap.set("title", view.title); + } + if (view.visibleColumnIds) { + const visibleColumnIds = this._adapter.createArray() as CRDTUnknownArray; + if (view.visibleColumnIds.length > 0) { + visibleColumnIds.insert(0, view.visibleColumnIds); + } + viewMap.set("visibleColumnIds", visibleColumnIds); + } + if (view.columnOrder) { + const columnOrder = this._adapter.createArray() as CRDTUnknownArray; + if (view.columnOrder.length > 0) { + columnOrder.insert(0, view.columnOrder); + } + viewMap.set("columnOrder", columnOrder); + } + if (view.sort) { + const sort = this._adapter.createArray() as CRDTUnknownArray; + if (view.sort.length > 0) { + sort.insert(0, view.sort.map((entry) => this.createRecordMap(entry))); + } + viewMap.set("sort", sort); + } + if (view.filter) { + viewMap.set("filter", this.createNestedRecord(view.filter)); + } + if (view.groupBy !== undefined) { + if (view.groupBy === null) { + viewMap.set("groupBy", null); + } else { + viewMap.set("groupBy", view.groupBy); + } + } + if (view.rowPinning) { + viewMap.set("rowPinning", this.createNestedRecord(view.rowPinning)); + } + if (view.pageIndex !== undefined) { + viewMap.set("pageIndex", view.pageIndex); + } + if (view.pageSize !== undefined) { + viewMap.set("pageSize", view.pageSize); + } + return viewMap; + } + + createRecordMap(record: object): DatabaseViewMap { + const map = this._adapter.createMap() as DatabaseViewMap; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) { + map.set(key, value); + } + } + return map; + } + + createNestedRecord(record: object): DatabaseViewMap { + const map = this._adapter.createMap() as DatabaseViewMap; + for (const [key, value] of Object.entries(record)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + const array = this._adapter.createArray() as CRDTUnknownArray; + if (value.length > 0) { + array.insert( + 0, + value.map((entry) => + entry && typeof entry === "object" + ? this.createNestedRecord(entry) + : entry, + ), + ); + } + map.set(key, array); + continue; + } + if (value && typeof value === "object") { + map.set(key, this.createNestedRecord(value)); + continue; + } + map.set(key, value); + } + return map; + } + + private _readColumnIds(blockMap: CRDTUnknownMap): string[] { + return this._tableGrid.readColumnIds(getTableColumns(blockMap)); + } + + private _readRowIds(blockMap: CRDTUnknownMap): string[] { + const tableContent = getTableContent(blockMap); + if (!tableContent) { + return []; + } + + const rowIds: string[] = []; + for (let index = 0; index < tableContent.length; index++) { + const row = tableContent.get(index); + if (!row || !isCRDTMap(row)) { + continue; + } + const rowId = getStringProp(row, "id"); + if (rowId) { + rowIds.push(rowId); + } + } + return rowIds; + } + + private _normalizeColumnIdList( + values: string[], + columnIdSet: Set, + ): string[] { + const seen = new Set(); + return values.filter((value) => { + if (!columnIdSet.has(value) || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }); + } + + private _normalizeSort( + sorts: DatabaseSort[], + columnIdSet: Set, + ): DatabaseSort[] { + const seen = new Set(); + return sorts.filter((sort) => { + if ( + !columnIdSet.has(sort.columnId) || + (sort.direction !== "asc" && sort.direction !== "desc") || + seen.has(sort.columnId) + ) { + return false; + } + seen.add(sort.columnId); + return true; + }); + } + + private _normalizeRowPinning( + rowPinning: DatabaseRowPinning, + rowIdSet: Set, + ): DatabaseRowPinning { + const top = this._normalizeUniqueIds(rowPinning.top ?? [], rowIdSet); + const bottom = this._normalizeUniqueIds( + (rowPinning.bottom ?? []).filter((rowId) => !top.includes(rowId)), + rowIdSet, + ); + return { + ...(top.length > 0 ? { top } : {}), + ...(bottom.length > 0 ? { bottom } : {}), + }; + } + + private _normalizeUniqueIds(values: string[], allowed: Set): string[] { + const seen = new Set(); + return values.filter((value) => { + if (!allowed.has(value) || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }); + } + + private _normalizeFilterGroup( + group: FilterGroup, + columnIdSet: Set, + ): FilterGroup | null { + const conditions: Array = + group.conditions.flatMap((condition) => { + if (this._isFilterGroup(condition)) { + const nestedGroup = this._normalizeFilterGroup(condition, columnIdSet); + return nestedGroup ? [nestedGroup] : []; + } + return this._isValidFilterCondition(condition, columnIdSet) + ? [condition] + : []; + }); + + if (conditions.length === 0) { + return null; + } + + return { + operator: group.operator === "or" ? "or" : "and", + conditions, + }; + } + + private _isFilterGroup( + value: FilterCondition | FilterGroup, + ): value is FilterGroup { + return Array.isArray((value as FilterGroup).conditions); + } + + private _isValidFilterCondition( + condition: FilterCondition, + columnIdSet: Set, + ): boolean { + return columnIdSet.has(condition.columnId); + } +} diff --git a/packages/core/src/editor/documentSession.ts b/packages/core/src/editor/documentSession.ts index b9f5580..c762976 100644 --- a/packages/core/src/editor/documentSession.ts +++ b/packages/core/src/editor/documentSession.ts @@ -2,10 +2,8 @@ import type { Awareness, CRDTAdapter, CRDTDocument, - CRDTEvent, CreateSubdocumentOptions, DocumentScope, - DocumentScopeInfo, DocumentScopeLookupOptions, DocumentScopeReplacementEvent, DocumentSessionAttachOptions, @@ -23,25 +21,23 @@ import { type YjsDoc, type YjsCRDTDocument, } from "@pen/crdt-yjs"; - -type ScopeListener = (event: CRDTEvent) => void; -type ScopeReplacementListener = (event: DocumentScopeReplacementEvent) => void; - -type ScopeReplacementTarget = { - previousScope: DocumentScope; - ownerPath: string[]; -}; - -type ScopeEntry = { - scope: DocumentScope; - awareness: Awareness | null; - observerUnsub: Unsubscribe; - subdocsHandler: ((event: { - added: Set; - loaded: Set; - removed: Set; - }) => void) | null; -}; +import { + cloneScope, + collectReplacementTargets, + emitScopeEvent, + findExistingScopeId, + findRegisteredScopeForBlock, + getDocumentGuid, + indexOwnerScope, + removeOwnerIndex, + resolveReplacementScope, + toOwnerKey, + toScopeId, + toScopeInfo, + type ScopeEntry, + type ScopeListener, + type ScopeReplacementListener, +} from "./documentSessionHelpers"; export interface CreateDocumentSessionOptions { adapter: CRDTAdapter; @@ -50,34 +46,6 @@ export interface CreateDocumentSessionOptions { ownsDocuments?: boolean; } -function getDocumentGuid(doc: YjsDoc): string { - const guid = (doc as YjsDoc & { guid?: string }).guid; - return typeof guid === "string" && guid.length > 0 - ? guid - : `doc-${doc.clientID}`; -} - -function toScopeId(doc: CRDTDocument): string { - if (!isYjsCRDTDocument(doc)) { - return `scope-${Math.random().toString(36).slice(2)}`; - } - return getDocumentGuid(doc.ydoc); -} - -function cloneScope(scope: DocumentScope): DocumentScope { - return { ...scope }; -} - -function toScopeInfo(scope: DocumentScope): DocumentScopeInfo { - return { - id: scope.id, - guid: scope.guid, - kind: scope.kind, - parentId: scope.parentId, - ownerBlockId: scope.ownerBlockId, - }; -} - export class DocumentSessionImpl implements DocumentSession { readonly adapter: CRDTAdapter; readonly rootScope: DocumentScope; @@ -121,12 +89,17 @@ export class DocumentSessionImpl implements DocumentSession { const scopeId = options?.scopeId; if (scopeId) { const ownedScopeId = this._scopeIdsByOwnerKey.get( - this._toOwnerKey(scopeId, blockId), + toOwnerKey(scopeId, blockId), ); if (ownedScopeId) { return this._getScope(ownedScopeId); } - return this._findRegisteredScopeForBlock(scopeId, blockId); + const parentEntry = this._getScopeEntry(scopeId); + return parentEntry + ? findRegisteredScopeForBlock(parentEntry.scope.doc, blockId, (guid) => + this.getScopeByGuid(guid), + ) + : null; } let match: DocumentScope | null = null; @@ -240,7 +213,10 @@ export class DocumentSessionImpl implements DocumentSession { const previousDoc = entry.scope.doc; const previousGuid = entry.scope.guid; - const replacementTargets = this._collectReplacementTargets(scopeId); + const replacementTargets = collectReplacementTargets( + entry.scope, + this._scopes.values(), + ); this._removeDescendantScopes(scopeId); entry.observerUnsub(); @@ -275,7 +251,12 @@ export class DocumentSessionImpl implements DocumentSession { for (const target of replacementTargets) { const event: DocumentScopeReplacementEvent = { previousScope: toScopeInfo(target.previousScope), - scope: this._resolveReplacementScope(scopeId, target.ownerPath), + scope: resolveReplacementScope( + this.getScope(scopeId) ?? cloneScope(this.rootScope), + target.ownerPath, + (ownerBlockId, currentScopeId) => + this.getScopeForBlock(ownerBlockId, { scopeId: currentScopeId }), + ), }; for (const listener of this._scopeReplacementListeners) { listener(event); @@ -333,14 +314,14 @@ export class DocumentSessionImpl implements DocumentSession { doc: CRDTDocument, location: { parentId: string | null; ownerBlockId: string | null }, ): DocumentScope { - const existingId = this._findExistingScopeId(doc); + const existingId = findExistingScopeId(doc, this._scopes.entries()); if (existingId) { const existing = this._scopes.get(existingId); if (existing) { - this._removeOwnerIndex(existing.scope); + removeOwnerIndex(this._scopeIdsByOwnerKey, existing.scope); existing.scope.parentId = location.parentId; existing.scope.ownerBlockId = location.ownerBlockId; - this._indexOwnerScope(existing.scope); + indexOwnerScope(this._scopeIdsByOwnerKey, existing.scope); return cloneScope(existing.scope); } } @@ -366,7 +347,7 @@ export class DocumentSessionImpl implements DocumentSession { this._scopes.set(scopeId, entry); this._guidToScopeId.set(scope.guid, scope.id); - this._indexOwnerScope(scope); + indexOwnerScope(this._scopeIdsByOwnerKey, scope); if (isYjsCRDTDocument(doc)) { this._attachSubdocumentDiscovery(entry, doc); @@ -443,7 +424,7 @@ export class DocumentSessionImpl implements DocumentSession { if (entry.subdocsHandler && isYjsCRDTDocument(entry.scope.doc)) { entry.scope.doc.ydoc.off("subdocs", entry.subdocsHandler); } - this._removeOwnerIndex(entry.scope); + removeOwnerIndex(this._scopeIdsByOwnerKey, entry.scope); this._scopes.delete(scopeId); this._guidToScopeId.delete(guid); this._listenersByScope.delete(scopeId); @@ -463,110 +444,6 @@ export class DocumentSessionImpl implements DocumentSession { } } - private _collectReplacementTargets(scopeId: string): ScopeReplacementTarget[] { - const rootEntry = this._getScopeEntry(scopeId); - if (!rootEntry) { - return []; - } - - const targets: ScopeReplacementTarget[] = []; - const walk = (currentScope: DocumentScope, ownerPath: string[]) => { - targets.push({ - previousScope: cloneScope(currentScope), - ownerPath: [...ownerPath], - }); - - const childScopes = Array.from(this._scopes.values()) - .filter((entry) => entry.scope.parentId === currentScope.id) - .map((entry) => cloneScope(entry.scope)); - - for (const childScope of childScopes) { - if (childScope.ownerBlockId == null) { - walk(childScope, ownerPath); - continue; - } - walk(childScope, [...ownerPath, childScope.ownerBlockId]); - } - }; - - walk(cloneScope(rootEntry.scope), []); - return targets; - } - - private _resolveReplacementScope( - scopeId: string, - ownerPath: readonly string[], - ): DocumentScope { - let resolvedScope = this.getScope(scopeId) ?? cloneScope(this.rootScope); - for (const ownerBlockId of ownerPath) { - const childScope = this.getScopeForBlock(ownerBlockId, { - scopeId: resolvedScope.id, - }); - if (!childScope) { - break; - } - resolvedScope = childScope; - } - return resolvedScope; - } - - private _emit(scope: DocumentScope, event: CRDTEvent): void { - const scopedEvent: CRDTEvent = { - ...event, - scope: { - id: scope.id, - guid: scope.guid, - kind: scope.kind, - parentId: scope.parentId, - ownerBlockId: scope.ownerBlockId, - }, - }; - - const scopeListeners = this._listenersByScope.get(scope.id); - if (scopeListeners) { - for (const listener of scopeListeners) { - listener(scopedEvent); - } - } - - for (const listener of this._allListeners) { - listener(scopedEvent); - } - } - - private _findExistingScopeId(doc: CRDTDocument): string | null { - for (const [scopeId, entry] of this._scopes.entries()) { - if (entry.scope.doc === doc) { - return scopeId; - } - if ( - isYjsCRDTDocument(entry.scope.doc) && - isYjsCRDTDocument(doc) && - entry.scope.doc.ydoc === doc.ydoc - ) { - return scopeId; - } - } - return null; - } - - private _findRegisteredScopeForBlock( - scopeId: string, - blockId: string, - ): DocumentScope | null { - const parentEntry = this._getScopeEntry(scopeId); - if (!parentEntry || !isYjsCRDTDocument(parentEntry.scope.doc)) { - return null; - } - const subdoc = parentEntry.scope.doc.penDocument.blocks.get(blockId)?.get( - SUBDOCUMENT, - ); - if (!isYjsDoc(subdoc)) { - return null; - } - return this.getScopeByGuid(getDocumentGuid(subdoc)); - } - private _syncOwnedSubdocumentScopes( entry: ScopeEntry, blockIds?: Iterable, @@ -591,29 +468,6 @@ export class DocumentSessionImpl implements DocumentSession { } } - private _indexOwnerScope(scope: DocumentScope): void { - if (!scope.parentId || !scope.ownerBlockId) { - return; - } - this._scopeIdsByOwnerKey.set( - this._toOwnerKey(scope.parentId, scope.ownerBlockId), - scope.id, - ); - } - - private _removeOwnerIndex(scope: DocumentScope): void { - if (!scope.parentId || !scope.ownerBlockId) { - return; - } - this._scopeIdsByOwnerKey.delete( - this._toOwnerKey(scope.parentId, scope.ownerBlockId), - ); - } - - private _toOwnerKey(scopeId: string, blockId: string): string { - return `${scopeId}:${blockId}`; - } - private _getScope(scopeId: string): DocumentScope | null { const entry = this._getScopeEntry(scopeId); return entry ? cloneScope(entry.scope) : null; @@ -626,7 +480,12 @@ export class DocumentSessionImpl implements DocumentSession { private _createScopeObserver(entry: ScopeEntry): Unsubscribe { return this.adapter.observe(entry.scope.doc, (event) => { this._syncOwnedSubdocumentScopes(entry, event.affectedBlocks); - this._emit(entry.scope, event); + emitScopeEvent( + entry.scope, + event, + this._listenersByScope, + this._allListeners, + ); }); } } diff --git a/packages/core/src/editor/documentSessionHelpers.ts b/packages/core/src/editor/documentSessionHelpers.ts new file mode 100644 index 0000000..e1a074a --- /dev/null +++ b/packages/core/src/editor/documentSessionHelpers.ts @@ -0,0 +1,193 @@ +import type { + Awareness, + CRDTDocument, + CRDTEvent, + DocumentScope, + DocumentScopeInfo, + DocumentScopeReplacementEvent, + Unsubscribe, +} from "@pen/types"; +import { + SUBDOCUMENT, + isYjsCRDTDocument, + isYjsDoc, + type YjsDoc, +} from "@pen/crdt-yjs"; + +export type ScopeListener = (event: CRDTEvent) => void; +export type ScopeReplacementListener = ( + event: DocumentScopeReplacementEvent, +) => void; + +export type ScopeReplacementTarget = { + previousScope: DocumentScope; + ownerPath: string[]; +}; + +export type ScopeEntry = { + scope: DocumentScope; + awareness: Awareness | null; + observerUnsub: Unsubscribe; + subdocsHandler: ((event: { + added: Set; + loaded: Set; + removed: Set; + }) => void) | null; +}; + +export function getDocumentGuid(doc: YjsDoc): string { + const guid = (doc as YjsDoc & { guid?: string }).guid; + return typeof guid === "string" && guid.length > 0 + ? guid + : `doc-${doc.clientID}`; +} + +export function toScopeId(doc: CRDTDocument): string { + if (!isYjsCRDTDocument(doc)) { + return `scope-${Math.random().toString(36).slice(2)}`; + } + return getDocumentGuid(doc.ydoc); +} + +export function cloneScope(scope: DocumentScope): DocumentScope { + return { ...scope }; +} + +export function toScopeInfo(scope: DocumentScope): DocumentScopeInfo { + return { + id: scope.id, + guid: scope.guid, + kind: scope.kind, + parentId: scope.parentId, + ownerBlockId: scope.ownerBlockId, + }; +} + +export function collectReplacementTargets( + rootScope: DocumentScope, + entries: Iterable, +): ScopeReplacementTarget[] { + const allEntries = Array.from(entries); + const targets: ScopeReplacementTarget[] = []; + const walk = (currentScope: DocumentScope, ownerPath: string[]) => { + targets.push({ + previousScope: cloneScope(currentScope), + ownerPath: [...ownerPath], + }); + + const childScopes = allEntries + .filter((entry) => entry.scope.parentId === currentScope.id) + .map((entry) => cloneScope(entry.scope)); + + for (const childScope of childScopes) { + if (childScope.ownerBlockId == null) { + walk(childScope, ownerPath); + continue; + } + walk(childScope, [...ownerPath, childScope.ownerBlockId]); + } + }; + + walk(cloneScope(rootScope), []); + return targets; +} + +export function resolveReplacementScope( + initialScope: DocumentScope, + ownerPath: readonly string[], + getChildScope: ( + ownerBlockId: string, + currentScopeId: string, + ) => DocumentScope | null, +): DocumentScope { + let resolvedScope = cloneScope(initialScope); + for (const ownerBlockId of ownerPath) { + const childScope = getChildScope(ownerBlockId, resolvedScope.id); + if (!childScope) { + break; + } + resolvedScope = childScope; + } + return resolvedScope; +} + +export function emitScopeEvent( + scope: DocumentScope, + event: CRDTEvent, + listenersByScope: Map>, + allListeners: Set, +): void { + const scopedEvent: CRDTEvent = { + ...event, + scope: toScopeInfo(scope), + }; + + const scopeListeners = listenersByScope.get(scope.id); + if (scopeListeners) { + for (const listener of scopeListeners) { + listener(scopedEvent); + } + } + + for (const listener of allListeners) { + listener(scopedEvent); + } +} + +export function findExistingScopeId( + doc: CRDTDocument, + entries: Iterable<[string, ScopeEntry]>, +): string | null { + for (const [scopeId, entry] of entries) { + if (entry.scope.doc === doc) { + return scopeId; + } + if ( + isYjsCRDTDocument(entry.scope.doc) && + isYjsCRDTDocument(doc) && + entry.scope.doc.ydoc === doc.ydoc + ) { + return scopeId; + } + } + return null; +} + +export function findRegisteredScopeForBlock( + parentDoc: CRDTDocument, + blockId: string, + getScopeByGuid: (guid: string) => DocumentScope | null, +): DocumentScope | null { + if (!isYjsCRDTDocument(parentDoc)) { + return null; + } + const subdoc = parentDoc.penDocument.blocks.get(blockId)?.get(SUBDOCUMENT); + if (!isYjsDoc(subdoc)) { + return null; + } + return getScopeByGuid(getDocumentGuid(subdoc)); +} + +export function indexOwnerScope( + scopeIdsByOwnerKey: Map, + scope: DocumentScope, +): void { + if (!scope.parentId || !scope.ownerBlockId) { + return; + } + scopeIdsByOwnerKey.set(toOwnerKey(scope.parentId, scope.ownerBlockId), scope.id); +} + +export function removeOwnerIndex( + scopeIdsByOwnerKey: Map, + scope: DocumentScope, +): void { + if (!scope.parentId || !scope.ownerBlockId) { + return; + } + scopeIdsByOwnerKey.delete(toOwnerKey(scope.parentId, scope.ownerBlockId)); +} + +export function toOwnerKey(scopeId: string, blockId: string): string { + return `${scopeId}:${blockId}`; +} diff --git a/packages/core/src/editor/editor.ts b/packages/core/src/editor/editor.ts index 58da3b5..6f71d15 100644 --- a/packages/core/src/editor/editor.ts +++ b/packages/core/src/editor/editor.ts @@ -1,41 +1,5 @@ -import type { - Editor, - EditorInternals, - CreateEditorOptions, - PenEventMap, - DocumentCommitEvent, - CRDTAdapter, - CRDTDocument, - CRDTEvent, - PenDocument, - SchemaRegistry, - Awareness, - DocumentSession, - DocumentScope, - DocumentScopeReplacementEvent, - DocumentProfile, - Extension, - DocumentOp, - ApplyOptions, - SelectionState, - TextSelection, - DocumentRange, - BlockHandle, - Block, - DocumentState, - UndoManager, - Unsubscribe, - CRDTMap, - CRDTArray, - Position, - DecorationSet, - EditorViewMode, -} from "@pen/types"; -import { - AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, - COLLECT_KEY_BINDINGS_SLOT_KEY, - usesInlineTextSelection, -} from "@pen/types"; +import type { Editor, EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; import { yjsAdapter } from "@pen/crdt-yjs"; import { undoExtension } from "@pen/undo"; import { documentOpsExtension } from "@pen/document-ops"; @@ -57,39 +21,13 @@ import { emptyDecorationSet } from "./decorations"; import { DocumentRangeImpl } from "./range"; import { createDocumentSession } from "./documentSession"; +import { getRawBlockMap, getEditorInternals, applyEditorOps, recordMutationGroupMetadata, loadEditorDocument, iterateBlocks, getEditorBlock, getFirstBlock, getLastBlock, getBlockCount, getEditorBlockRevision, destroyEditor } from "./editorApiHelpers"; +import { createPenDocumentForEditor, resolveEditorExtensions, installProfilePolicyHook, enforceDocumentProfileBoundary, refreshCoreSlots, bindEditorSession, bindEditorScope, handleEditorScopeReplacement, resolveEditorDocumentProfile, rebindActiveScope, refreshUndoManager, activateEditorExtensions, queueExtensionLifecycle, ensureInitialParagraph, createCommitEvent, dispatchCRDTEvent, syncDocumentProfileFromStorage, wireEditorObservation, teardownEditorObservation } from "./editorLifecycle"; +import { replaceEditorSelection, deleteEditorSelection, getTextForBlock, getSelectionRange, usesInlineTextSelectionForBlock, getBlockSelectionSpan, isWholeBlockSelection, collapseToPoint, sliceInlineDeltas, buildMultiBlockTextReplacement, deleteMultiBlockTextRange, replaceMultiBlockTextRange } from "./editorSelectionMutations"; type CRDTBlockMap = CRDTMap>; -type RawPenDocumentLike = { - getArray?(name: "blockOrder"): CRDTArray; - getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; - blockOrder?: CRDTArray; - blocks?: CRDTMap; - apps?: CRDTMap; - metadata?: CRDTMap; -}; - -let hasWarnedAboutWithoutOption = false; - -function createGeneratedBlockId(): string { - return crypto.randomUUID(); -} - -function missingPenDocumentRoot(name: string): never { - throw new Error(`CRDT document is missing required Pen root "${name}".`); -} - // Stub undo manager for when @pen/undo is excluded -const NOOP_UNDO: UndoManager = { - undo: () => false, - redo: () => false, - canUndo: () => false, - canRedo: () => false, - stopCapturing: () => { }, - syncExplicitUndoGroup: () => { }, - setGroupTimeout: () => { }, - registerTrackedOrigins: () => () => { }, - onStackChange: () => () => { }, -}; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; class EditorImpl implements Editor { private readonly _adapter: CRDTAdapter; @@ -124,7 +62,8 @@ class EditorImpl implements Editor { constructor(options: CreateEditorOptions = {}) { this._registry = options.schema ?? builtInDefaultSchema; this._explicitEditorViewMode = options.editorViewMode ?? null; - this._adapter = options.documentSession?.adapter ?? options.crdt ?? yjsAdapter(); + this._adapter = + options.documentSession?.adapter ?? options.crdt ?? yjsAdapter(); const documentSession = options.documentSession ?? createDocumentSession({ @@ -134,7 +73,9 @@ class EditorImpl implements Editor { ownsDocuments: options.document == null, }); this._bindSession(documentSession, options.documentScopeId); - this._documentProfile = this._resolveDocumentProfile(options.documentProfile); + this._documentProfile = this._resolveDocumentProfile( + options.documentProfile, + ); this._editorViewMode = this._explicitEditorViewMode ?? this._documentProfile; this._clientId = this._adapter.getClientId(this._crdtDoc); @@ -220,87 +161,17 @@ class EditorImpl implements Editor { return this._documentState; } - private _getRawBlockMap(blockId: string): CRDTUnknownMap | null { - const blockMap = (this._doc.blocks as CRDTBlockMap).get(blockId); - return (blockMap as unknown as CRDTUnknownMap) ?? null; - } + private _getRawBlockMap(blockId: string): CRDTUnknownMap | null { return getRawBlockMap(this, blockId); } - get internals(): EditorInternals { - return { - adapter: this._adapter, - crdtDoc: this._crdtDoc, - doc: this._doc, - engine: this._engine, - awareness: this._awareness, - documentSession: this._documentSession, - documentScope: this._documentScope, - viewId: this._viewId, - emit: (event, ...args) => { - this._emitter.emit(event, ...args); - }, - onApplyBoundary: (hook) => this._pipeline.addApplyBoundaryHook(hook), - getSlot: (key: string): T | undefined => - this._slots.get(key) as T | undefined, - setSlot: (key: string, value: unknown): void => { - this._slots.set(key, value); - if (key === "undo:manager") { - this._refreshUndoManager(); - } - }, - getBlockText: (blockId: string): unknown => { - const blockMap = this._getRawBlockMap(blockId); - if (!blockMap) return null; - return getTextProp(blockMap, "content"); - }, - getCellText: (blockId: string, row: number, col: number): unknown => { - const blockMap = this._getRawBlockMap(blockId); - if (!blockMap) return null; - const tableContent = getTableContent(blockMap); - if (!tableContent || row < 0 || row >= tableContent.length) return null; - const rowMap = tableContent.get(row); - if (!rowMap || !isCRDTMap(rowMap)) return null; - return getCellTextFromRow(rowMap, col); - }, - }; - } + get internals(): EditorInternals { return getEditorInternals(this); } // ── Mutations ──────────────────────────────────────────── - apply(ops: DocumentOp[], options?: ApplyOptions): void { - const origin = options?.origin ?? "user"; - const undo = this._slots.get("undo:manager") as - | UndoManager - | undefined; + apply(ops: DocumentOp[], options?: ApplyOptions): void { applyEditorOps(this, ops, options); } - undo?.syncExplicitUndoGroup(options?.undoGroupId ?? null); + private _recordMutationGroupMetadata(origin: OpOrigin, groupId: string | undefined): void { recordMutationGroupMetadata(this, origin, groupId); } - if (options?.undoGroup && !options?.undoGroupId) { - undo?.stopCapturing(); - } - - this._pipeline.apply(ops, origin); - } - - loadDocument(doc: CRDTDocument): void { - this._queueExtensionLifecycle(async () => { - await this._extensions.deactivateAll(this); - if (this._isDestroyed) { - return; - } - this._teardownObservation(); - this._releaseSession?.(); - this._releaseSession = null; - this._bindSession( - createDocumentSession({ - adapter: this._adapter, - document: doc, - destroyWhenIdle: true, - ownsDocuments: false, - }), - ); - await this._rebindActiveScope(); - }); - } + loadDocument(doc: CRDTDocument): void { loadEditorDocument(this, doc); } onBeforeApply( hook: (ops: DocumentOp[], options: ApplyOptions) => DocumentOp[], @@ -314,56 +185,17 @@ class EditorImpl implements Editor { // ── Block Traversal ────────────────────────────────────── - *blocks(type?: string): Iterable { - for (let i = 0; i < this._doc.blockOrder.length; i++) { - const id = (this._doc.blockOrder as CRDTArray).get( - i, - ) as string; - if (type) { - const blockMap = (this._doc.blocks as CRDTBlockMap).get(id); - if (!blockMap || blockMap.get("type") !== type) continue; - } - yield createBlockHandle( - id, - this._doc, - this._crdtDoc, - this._registry, - ); - } - } + *blocks(type?: string): Iterable { yield* iterateBlocks(this, type); } - getBlock(blockId: string): BlockHandle | null { - if (!(this._doc.blocks as CRDTBlockMap).has(blockId)) return null; - return createBlockHandle( - blockId, - this._doc, - this._crdtDoc, - this._registry, - ); - } + getBlock(blockId: string): BlockHandle | null { return getEditorBlock(this, blockId); } - firstBlock(): BlockHandle | null { - if (this._doc.blockOrder.length === 0) return null; - const id = (this._doc.blockOrder as CRDTArray).get(0) as string; - return createBlockHandle(id, this._doc, this._crdtDoc, this._registry); - } + firstBlock(): BlockHandle | null { return getFirstBlock(this); } - lastBlock(): BlockHandle | null { - const len = this._doc.blockOrder.length; - if (len === 0) return null; - const id = (this._doc.blockOrder as CRDTArray).get( - len - 1, - ) as string; - return createBlockHandle(id, this._doc, this._crdtDoc, this._registry); - } + lastBlock(): BlockHandle | null { return getLastBlock(this); } - blockCount(): number { - return this._doc.blockOrder.length; - } + blockCount(): number { return getBlockCount(this); } - getBlockRevision(blockId: string): number { - return this._blockRevisions.get(blockId) ?? 0; - } + getBlockRevision(blockId: string): number { return getEditorBlockRevision(this, blockId); } // ── Selection ──────────────────────────────────────────── @@ -418,210 +250,9 @@ class EditorImpl implements Editor { return this._selection.getSelectedBlocks(); } - replaceSelection(content: string | Block[]): void { - const sel = this._selection.getSelection(); - if (!sel) return; - - if (sel.type === "text") { - const range = this._getSelectionRange(sel); - if (range.isMultiBlock) { - if (typeof content === "string") { - this._replaceMultiBlockTextRange(range, content); - } - return; - } - - const from = range.start.offset; - const to = range.end.offset; - const ops: DocumentOp[] = []; - if (to > from) { - ops.push({ - type: "delete-text", - blockId: range.start.blockId, - offset: from, - length: to - from, - }); - } - if (typeof content === "string" && content.length > 0) { - ops.push({ - type: "insert-text", - blockId: range.start.blockId, - offset: from, - text: content, - }); - } - if (ops.length > 0) { - this.apply(ops); - } - const nextOffset = - typeof content === "string" ? from + content.length : from; - this._collapseToPoint({ - blockId: range.start.blockId, - offset: nextOffset, - }); - return; - } - - if (sel.type === "block" && sel.blockIds.length > 0) { - const firstId = sel.blockIds[0]; - const firstIndex = this._pipeline._resolvePosition({ - before: firstId, - }); - const ops: DocumentOp[] = []; - - for (const id of sel.blockIds) { - ops.push({ type: "delete-block", blockId: id }); - } - - const insertPosition: Position = - firstIndex === 0 - ? "first" - : { - after: ( - this._doc.blockOrder as CRDTArray - ).get(firstIndex - 1) as string, - }; - - if (typeof content === "string") { - const newId = createGeneratedBlockId(); - ops.push({ - type: "insert-block", - blockId: newId, - blockType: "paragraph", - props: {}, - position: insertPosition, - }); - if (content.length > 0) { - ops.push({ - type: "insert-text", - blockId: newId, - offset: 0, - text: content, - }); - } - } else if (Array.isArray(content)) { - let prevPosition = insertPosition; - for (const block of content) { - const newId = createGeneratedBlockId(); - ops.push({ - type: "insert-block", - blockId: newId, - blockType: block.type, - props: block.props ?? {}, - position: prevPosition, - }); - if ( - typeof block.content === "string" && - block.content.length > 0 - ) { - ops.push({ - type: "insert-text", - blockId: newId, - offset: 0, - text: block.content, - }); - } - prevPosition = { after: newId }; - } - } - - this.apply(ops); - } - } - - deleteSelection(options?: ApplyOptions): void { - const sel = this._selection.getSelection(); - if (!sel) return; - - if (sel.type === "text") { - const range = this._getSelectionRange(sel); - if (range.isMultiBlock) { - this._deleteMultiBlockTextRange(range, options); - return; - } - - if ( - !this._usesInlineTextSelection(range.start.blockId) && - this._isWholeBlockSelection( - range.start.blockId, - range.start.offset, - range.end.offset, - ) - ) { - this.apply( - [ - { - type: "delete-block", - blockId: range.start.blockId, - }, - ], - options, - ); - this.setSelection(null); - return; - } - - const from = range.start.offset; - const to = range.end.offset; - if (to > from) { - this.apply( - [ - { - type: "delete-text", - blockId: range.start.blockId, - offset: from, - length: to - from, - }, - ], - options, - ); - } - this._collapseToPoint({ - blockId: range.start.blockId, - offset: from, - }); - return; - } + replaceSelection(content: string | Block[]): void { replaceEditorSelection(this, content); } - if (sel.type === "block") { - const ops: DocumentOp[] = sel.blockIds.map((id) => ({ - type: "delete-block" as const, - blockId: id, - })); - this.apply(ops, options); - this.setSelection(null); - } - - if (sel.type === "cell") { - const block = this.getBlock(sel.blockId); - if (!block) return; - const ops: DocumentOp[] = []; - for (const rowCells of resolveCellSelectionMatrix(block, sel)) { - for (const cellCoord of rowCells) { - const cell = block.tableCell(cellCoord.row, cellCoord.col); - if (!cell) continue; - const len = cell.length(); - if (len > 0) { - ops.push({ - type: "delete-table-cell-text", - blockId: sel.blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: 0, - length: len, - } as DocumentOp); - } - } - } - if (ops.length > 0) { - this.apply(ops, options); - } - this.setSelection({ - ...sel, - head: sel.anchor, - }); - } - } + deleteSelection(options?: ApplyOptions): void { deleteEditorSelection(this, options); } // ── Decorations ────────────────────────────────────────── @@ -679,611 +310,97 @@ class EditorImpl implements Editor { // ── Destroy ────────────────────────────────────────────── - destroy(): void { - if (this._isDestroyed) { - return; - } - this._isDestroyed = true; - this._queueExtensionLifecycle(async () => { - await this._extensions.deactivateAll(this); - this._teardownObservation(); - this._releaseSession?.(); - this._releaseSession = null; - this._emitter.removeAllListeners(); - }); - } + destroy(): void { destroyEditor(this); } // ── Private ────────────────────────────────────────────── - private _createPenDocument(crdtDoc: CRDTDocument): PenDocument { - const wrapped = crdtDoc as CRDTDocument & { penDocument?: PenDocument }; - if (wrapped.penDocument) { - return wrapped.penDocument; - } + private _createPenDocument(crdtDoc: CRDTDocument): PenDocument { return createPenDocumentForEditor(this, crdtDoc); } - const raw = this._adapter.raw(crdtDoc); - const blockOrder = - (raw.getArray ? raw.getArray("blockOrder") : raw.blockOrder) ?? - missingPenDocumentRoot("blockOrder"); - const blocks = - (raw.getMap ? raw.getMap("blocks") : raw.blocks) ?? - missingPenDocumentRoot("blocks"); - const apps = - (raw.getMap ? raw.getMap("apps") : raw.apps) ?? - missingPenDocumentRoot("apps"); - const metadata = - (raw.getMap ? raw.getMap("metadata") : raw.metadata) ?? - missingPenDocumentRoot("metadata"); - return { - blockOrder, - blocks, - apps, - metadata, - adapter: this._adapter, - }; - } + private _resolveExtensions(options: CreateEditorOptions): Extension[] { return resolveEditorExtensions(this, options); } - private _resolveExtensions(options: CreateEditorOptions): Extension[] { - const without = new Set(options.without ?? []); - if (without.size > 0 && !hasWarnedAboutWithoutOption) { - hasWarnedAboutWithoutOption = true; - console.warn( - 'Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.', - ); - } - const defaultExtensions = - options.preset?.resolve({ - schema: this._registry, - documentProfile: this._documentProfile, - }).extensions ?? [ - documentOpsExtension(), - deltaStreamExtension(), - undoExtension(), - richTextShortcutsExtension(), - ]; - const defaults = defaultExtensions.filter((ext) => !without.has(ext.name)); - - const userExtensions = options.extensions ?? []; - return [...defaults, ...userExtensions]; - } + private _installProfilePolicyHook(): void { installProfilePolicyHook(this); } - private _installProfilePolicyHook(): void { - this._pipeline.setFinalBeforeApplyHook((ops) => - this._enforceDocumentProfileBoundary(ops), - ); - } + private _enforceDocumentProfileBoundary(ops: DocumentOp[]): DocumentOp[] { return enforceDocumentProfileBoundary(this, ops); } - private _enforceDocumentProfileBoundary(ops: DocumentOp[]): DocumentOp[] { - const result = filterOpsForDocumentProfile( - ops, - this._documentProfile, - this._registry, - ); + private _refreshCoreSlots(): void { refreshCoreSlots(this); } - for (const violation of result.violations) { - this._emitter.emit("diagnostic", { - code: "PEN_PROFILE_001", - level: "warn", - source: "profile-policy", - message: - `profile-policy: dropped ${violation.op.type} for disallowed ` + - `block type "${violation.blockType}" in ${violation.documentProfile} documents`, - remediation: - "Use a block type allowed by the active documentProfile or " + - "change the documentProfile before applying structural mutations.", - op: violation.op, - blockType: violation.blockType, - documentProfile: violation.documentProfile, - }); - } + private _bindSession(session: DocumentSession, scopeId?: string): void { bindEditorSession(this, session, scopeId); } - return result.ops; - } + private _bindScope(session: DocumentSession, scopeId?: string): void { bindEditorScope(this, session, scopeId); } - private _refreshCoreSlots(): void { - this._slots.set("core:engine", this._engine); - this._slots.set( - AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, - () => this._extensionLifecycle, - ); - this._slots.set( - COLLECT_KEY_BINDINGS_SLOT_KEY, - (registry: SchemaRegistry) => this._extensions.collectKeyBindings(registry), - ); - } + private _handleScopeReplacement(session: DocumentSession, event: DocumentScopeReplacementEvent): void { handleEditorScopeReplacement(this, session, event); } - private _bindSession( - session: DocumentSession, - scopeId?: string, - ): void { - this._bindScope(session, scopeId); - this._releaseSession = session.attachEditor({ - onScopeReplaced: (event) => { - this._handleScopeReplacement(session, event); - }, - }); - } + private _resolveDocumentProfile(requestedProfile?: DocumentProfile): DocumentProfile { return resolveEditorDocumentProfile(this, requestedProfile); } - private _bindScope( - session: DocumentSession, - scopeId?: string, - ): void { - this._documentSession = session; - const scope = - (scopeId ? session.getScope(scopeId) : null) ?? session.rootScope; - this._documentScope = scope; - this._crdtDoc = scope.doc; - this._doc = this._createPenDocument(scope.doc); - this._awareness = session.getAwareness(scope.id); - } + private async _rebindActiveScope(): Promise { await rebindActiveScope(this); } - private _handleScopeReplacement( - session: DocumentSession, - event: DocumentScopeReplacementEvent, - ): void { - if (event.previousScope.id !== this._documentScope.id) { - return; - } - this._queueExtensionLifecycle(async () => { - await this._extensions.deactivateAll(this); - if (this._isDestroyed) { - return; - } - this._teardownObservation(); - this._bindScope(session, event.scope.id); - await this._rebindActiveScope(); - }); - } + private _refreshUndoManager(): void { refreshUndoManager(this); } - private _resolveDocumentProfile( - requestedProfile?: DocumentProfile, - ): DocumentProfile { - const persistedProfile = - this._adapter.getDocumentProfile?.(this._crdtDoc) ?? null; - const resolvedProfile = persistedProfile ?? requestedProfile ?? "structured"; - if (persistedProfile == null) { - this._adapter.setDocumentProfile?.(this._crdtDoc, resolvedProfile); - } - return resolvedProfile; - } + private async _activateExtensions(): Promise { await activateEditorExtensions(this); } - private async _rebindActiveScope(): Promise { - this._documentProfile = this._resolveDocumentProfile(); - this._editorViewMode = - this._explicitEditorViewMode ?? this._documentProfile; - this._clientId = this._adapter.getClientId(this._crdtDoc); + private _queueExtensionLifecycle(task: () => Promise): void { queueExtensionLifecycle(this, task); } - this._engine = new SchemaEngineImpl( - this._registry, - this._doc, - this._crdtDoc, - ); - this._selection.updateDocument(this._doc, this._crdtDoc); - this._pipeline.updateDocument(this._doc, this._crdtDoc, this._engine); - this._documentState.updateDocument( - this._doc, - this._crdtDoc, - this._documentProfile, - ); - this._pipeline._init((event) => { - this._dispatchCRDTEvent(event); - }); - this._refreshCoreSlots(); + private _ensureInitialParagraph(): void { ensureInitialParagraph(this); } - this._wireObservation(); - await this._activateExtensions(); - this._engine.normalizeAll(); - this._refreshDecorations(); - } - - private _refreshUndoManager(): void { - const slotUndo = this._slots.get("undo:manager") as - | UndoManager - | undefined; - (this as { undoManager: UndoManager }).undoManager = - slotUndo ?? NOOP_UNDO; - } - - private async _activateExtensions(): Promise { - const activation = this._extensions.activateAll(this); - this._refreshUndoManager(); - await activation; - this._refreshUndoManager(); - } - - private _queueExtensionLifecycle(task: () => Promise): void { - const runTask = async (): Promise => { - try { - await task(); - } catch (error) { - if (this._isDestroyed) { - return; - } - this._emitter.emit("diagnostic", { - code: "PEN_EXT_006", - level: "error", - source: "extension", - message: "Editor extension lifecycle transition failed", - remediation: - "Inspect async extension activate/deactivate hooks involved in document reload or scope replacement and ensure they resolve safely.", - error, - }); - } - }; - - this._extensionLifecycle = this._extensionLifecycle.then(runTask, runTask); - } - - private _ensureInitialParagraph(): void { - if (this._doc.blockOrder.length > 0) { - return; - } - - this.apply( - [ - { - type: "insert-block", - blockId: createGeneratedBlockId(), - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - } - - private _createCommitEvent(event: CRDTEvent): DocumentCommitEvent { - const blockRevisions: Record = {}; - for (const blockId of event.affectedBlocks) { - const nextRevision = (this._blockRevisions.get(blockId) ?? 0) + 1; - this._blockRevisions.set(blockId, nextRevision); - blockRevisions[blockId] = nextRevision; - } - this._commitId += 1; - return { - commitId: this._commitId, - ops: event.ops, - origin: event.origin, - affectedBlocks: [...event.affectedBlocks], - blockRevisions, - scope: this._documentScope, - }; - } - - private _dispatchCRDTEvent(event: CRDTEvent): void { - this._syncDocumentProfileFromStorage(); - const commitEvent = this._createCommitEvent(event); - this._documentState.incrementalUpdate(event.affectedBlocks); - this._extensions.dispatchObserve([event], this); - const previousDecorationGeneration = this._decorations.generation; - const nextDecorations = this._refreshDecorations(); - if (nextDecorations.generation !== previousDecorationGeneration) { - this._emitter.emit("decorationsChange", nextDecorations.generation); - } - this._emitter.emit("change", [event]); - this._emitter.emit("documentCommit", commitEvent); - } - - private _syncDocumentProfileFromStorage(): void { - const persistedProfile = - this._adapter.getDocumentProfile?.(this._crdtDoc) ?? null; - if (!persistedProfile || persistedProfile === this._documentProfile) { - return; - } - - this._documentProfile = persistedProfile; - if (this._explicitEditorViewMode == null) { - this._editorViewMode = persistedProfile; - } - this._documentState.setDocumentProfile(persistedProfile); - } - - private _wireObservation(): void { - if (this._documentSession) { - this._unsubObserve = this._documentSession.observe( - this._documentScope.id, - (event: CRDTEvent) => { - if (this._pipeline.suppressObserver) return; - this._dispatchCRDTEvent(event); - }, - ); - return; - } - - this._unsubObserve = this._adapter.observe(this._crdtDoc, (event: CRDTEvent) => { - if (this._pipeline.suppressObserver) return; - this._dispatchCRDTEvent(event); - }); - } - - private _teardownObservation(): void { - if (this._unsubObserve) { - this._unsubObserve(); - this._unsubObserve = null; - } - } - - private _getTextForBlock(blockId: string): string { - return this.getBlock(blockId)?.textContent() ?? ""; - } - - private _getSelectionRange(sel: TextSelection): DocumentRange { - return sel.toRange(); - } - - private _usesInlineTextSelection(blockId: string): boolean { - const block = this.getBlock(blockId); - if (!block) { - return false; - } - - const schema = this._registry.resolve(block.type); - if (!schema) { - return false; - } - - return usesInlineTextSelection(schema); - } - - private _getBlockSelectionSpan(blockId: string): number { - if (this._usesInlineTextSelection(blockId)) { - return this._getTextForBlock(blockId).length; - } - return this.getBlock(blockId) ? 1 : 0; - } + private _createCommitEvent(event: CRDTEvent): DocumentCommitEvent { return createCommitEvent(this, event); } - private _isWholeBlockSelection( - blockId: string, - startOffset: number, - endOffset: number, - ): boolean { - const span = this._getBlockSelectionSpan(blockId); - if (span <= 0) { - return false; - } - return startOffset <= 0 && endOffset >= span; - } + private _dispatchCRDTEvent(event: CRDTEvent): void { dispatchCRDTEvent(this, event); } - private _collapseToPoint(point: { blockId: string; offset: number }): void { - this.selectTextRange(point, point); - } - - private _sliceInlineDeltas( - blockId: string, - startOffset: number, - ): Array<{ insert: string; attributes?: Record }> { - const handle = this.getBlock(blockId); - if (!handle) { - return []; - } - - const deltas = handle - .textDeltas() - .filter((delta) => delta.insert !== "\u200B"); - const sliced: Array<{ - insert: string; - attributes?: Record; - }> = []; - let offset = 0; - - for (const delta of deltas) { - const length = delta.insert.length; - if (startOffset >= offset + length) { - offset += length; - continue; - } - - const localStart = Math.max(0, startOffset - offset); - const text = delta.insert.slice(localStart); - if (text.length > 0) { - sliced.push({ - insert: text, - ...(delta.attributes - ? { attributes: delta.attributes } - : {}), - }); - } - offset += length; - } + private _syncDocumentProfileFromStorage(): void { syncDocumentProfileFromStorage(this); } - return sliced; - } + private _wireObservation(): void { wireEditorObservation(this); } - private _buildMultiBlockTextReplacement( - range: DocumentRange, - insertedText: string, - ): { ops: DocumentOp[]; caret: { blockId: string; offset: number } } { - const startId = range.start.blockId; - const endId = range.end.blockId; - const startText = this._getTextForBlock(startId); - const middleIds = range.blockRange.slice(1, -1); - const suffixDeltas = this._sliceInlineDeltas(endId, range.end.offset); - const ops: DocumentOp[] = []; - - if (range.start.offset < startText.length) { - ops.push({ - type: "delete-text", - blockId: startId, - offset: range.start.offset, - length: startText.length - range.start.offset, - }); - } + private _teardownObservation(): void { teardownEditorObservation(this); } - if (range.end.offset > 0) { - ops.push({ - type: "delete-text", - blockId: endId, - offset: 0, - length: range.end.offset, - }); - } + private _getTextForBlock(blockId: string): string { return getTextForBlock(this, blockId); } - for (const blockId of middleIds) { - ops.push({ - type: "delete-block", - blockId, - }); - } + private _getSelectionRange(sel: TextSelection): DocumentRange { return getSelectionRange(this, sel); } - let insertionOffset = range.start.offset; - if (insertedText.length > 0) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: insertedText, - }); - insertionOffset += insertedText.length; - } + private _usesInlineTextSelection(blockId: string): boolean { return usesInlineTextSelectionForBlock(this, blockId); } - for (const delta of suffixDeltas) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: delta.insert, - marks: delta.attributes, - }); - insertionOffset += delta.insert.length; - } + private _getBlockSelectionSpan(blockId: string): number { return getBlockSelectionSpan(this, blockId); } - ops.push({ - type: "delete-block", - blockId: endId, - }); + private _isWholeBlockSelection(blockId: string, startOffset: number, endOffset: number): boolean { return isWholeBlockSelection(this, blockId, startOffset, endOffset); } - return { - ops, - caret: { - blockId: startId, - offset: range.start.offset + insertedText.length, - }, - }; - } + private _collapseToPoint(point: { blockId: string; offset: number }): void { return collapseToPoint(this, point); } - private _deleteMultiBlockTextRange( - range: DocumentRange, - options?: ApplyOptions, - ): { blockId: string; offset: number } | null { - const startId = range.start.blockId; - const endId = range.end.blockId; - if (startId === endId) { - const from = range.start.offset; - const to = range.end.offset; - if (to > from) { - this.apply( - [ - { - type: "delete-text", - blockId: startId, - offset: from, - length: to - from, - }, - ], - options, - ); - } - const caret = { blockId: startId, offset: from }; - this._collapseToPoint(caret); - return caret; - } + private _sliceInlineDeltas(blockId: string, startOffset: number): Array<{ insert: string; attributes?: Record }> { return sliceInlineDeltas(this, blockId, startOffset); } - const startInline = this._usesInlineTextSelection(startId); - const endInline = this._usesInlineTextSelection(endId); - if (startInline && endInline) { - const { ops, caret } = this._buildMultiBlockTextReplacement(range, ""); - this.apply(ops, options); - this._collapseToPoint(caret); - return caret; - } + private _buildMultiBlockTextReplacement(range: DocumentRange, insertedText: string): { ops: DocumentOp[]; caret: { blockId: string; offset: number } } { return buildMultiBlockTextReplacement(this, range, insertedText); } - const middleIds = range.blockRange.slice(1, -1); - const ops: DocumentOp[] = []; - - if (startInline) { - const startText = this._getTextForBlock(startId); - if (range.start.offset < startText.length) { - ops.push({ - type: "delete-text", - blockId: startId, - offset: range.start.offset, - length: startText.length - range.start.offset, - }); - } - } else if ( - this._isWholeBlockSelection( - startId, - range.start.offset, - this._getBlockSelectionSpan(startId), - ) - ) { - ops.push({ - type: "delete-block", - blockId: startId, - }); - } + private _deleteMultiBlockTextRange(range: DocumentRange, options?: ApplyOptions): { blockId: string; offset: number } | null { return deleteMultiBlockTextRange(this, range, options); } - for (const blockId of middleIds) { - ops.push({ - type: "delete-block", - blockId, - }); - } + private _replaceMultiBlockTextRange(range: DocumentRange, text: string): { blockId: string; offset: number } { return replaceMultiBlockTextRange(this, range, text); } - if (endInline) { - if (range.end.offset > 0) { - ops.push({ - type: "delete-text", - blockId: endId, - offset: 0, - length: range.end.offset, - }); - } - } else if ( - this._isWholeBlockSelection( - endId, - 0, - range.end.offset, - ) - ) { - ops.push({ - type: "delete-block", - blockId: endId, - }); - } +} - if (ops.length > 0) { - this.apply(ops, options); - } +export function createEditor(options?: CreateEditorOptions): Editor { + return new EditorImpl(options); +} - const caret = startInline - ? { blockId: startId, offset: range.start.offset } - : endInline - ? { blockId: endId, offset: 0 } - : null; - if (caret) { - this._collapseToPoint(caret); - } else { - this.setSelection(null); - } - return caret; - } +const headlessPreset = { + resolve() { + return { extensions: [] }; + }, +}; - private _replaceMultiBlockTextRange( - range: DocumentRange, - text: string, - ): { blockId: string; offset: number } { - const { ops, caret } = this._buildMultiBlockTextReplacement( - range, - text, - ); - this.apply(ops); - this._collapseToPoint(caret); - return caret; - } +export interface CreateHeadlessEditorOptions extends CreateEditorOptions { + /** + * Headless server/workflow editors default to the core apply pipeline only. + * Enable default extensions when a host explicitly needs undo, shortcuts, or + * delta stream behavior in a non-rendered environment. + */ + useDefaultExtensions?: boolean; } -export function createEditor(options?: CreateEditorOptions): Editor { - return new EditorImpl(options); +export function createHeadlessEditor( + options: CreateHeadlessEditorOptions = {}, +): Editor { + const { useDefaultExtensions = false, ...editorOptions } = options; + return createEditor({ + ...editorOptions, + preset: + editorOptions.preset ?? + (useDefaultExtensions ? undefined : headlessPreset), + }); } diff --git a/packages/core/src/editor/editorApiHelpers.ts b/packages/core/src/editor/editorApiHelpers.ts new file mode 100644 index 0000000..e1166cb --- /dev/null +++ b/packages/core/src/editor/editorApiHelpers.ts @@ -0,0 +1,212 @@ +import type { EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; +import { undoExtension } from "@pen/undo"; +import { documentOpsExtension } from "@pen/document-ops"; +import { deltaStreamExtension } from "@pen/delta-stream"; +import { richTextShortcutsExtension } from "@pen/shortcuts"; +import { SchemaEngineImpl } from "../schema/normalize"; +import { createBlockHandle } from "../schema/handles"; +import { resolveCellSelectionMatrix } from "./cellSelection"; +import { filterOpsForDocumentProfile } from "./profilePolicy"; +import type { CRDTUnknownMap } from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { DocumentStateImpl } from "./documentState"; +import { createDocumentSession } from "./documentSession"; + +type EditorImplRuntime = any; +type CRDTBlockMap = CRDTMap>; +type RawPenDocumentLike = { getArray?(name: "blockOrder"): CRDTArray; getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; blockOrder?: CRDTArray; blocks?: CRDTMap; apps?: CRDTMap; metadata?: CRDTMap; }; +function createGeneratedBlockId(): string { return crypto.randomUUID(); } +function missingPenDocumentRoot(name: string): never { throw new Error(`CRDT document is missing required Pen root "${name}".`); } +let hasWarnedAboutWithoutOption = false; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; + + +export function getRawBlockMap(editor: EditorImplRuntime, blockId: string): CRDTUnknownMap | null { + const self = editor as EditorImplRuntime; +const blockMap = (self._doc.blocks as CRDTBlockMap).get(blockId); +return (blockMap as unknown as CRDTUnknownMap) ?? null; +} + +export function getEditorInternals(editor: EditorImplRuntime, ): EditorInternals { + const self = editor as EditorImplRuntime; +return { + adapter: self._adapter, + crdtDoc: self._crdtDoc, + doc: self._doc, + engine: self._engine, + awareness: self._awareness, + documentSession: self._documentSession, + documentScope: self._documentScope, + viewId: self._viewId, + emit: (event, ...args) => { + self._emitter.emit(event, ...args); + }, + onApplyBoundary: (hook) => + self._pipeline.addApplyBoundaryHook(hook), + getSlot: (key: string): T | undefined => + self._slots.get(key) as T | undefined, + setSlot: (key: string, value: unknown): void => { + self._slots.set(key, value); + if (key === "undo:manager") { + self._refreshUndoManager(); + } + }, + getBlockText: (blockId: string): unknown => { + const blockMap = self._getRawBlockMap(blockId); + if (!blockMap) return null; + return getTextProp(blockMap, "content"); + }, + getCellText: ( + blockId: string, + row: number, + col: number, + ): unknown => { + const blockMap = self._getRawBlockMap(blockId); + if (!blockMap) return null; + const tableContent = getTableContent(blockMap); + if (!tableContent || row < 0 || row >= tableContent.length) + return null; + const rowMap = tableContent.get(row); + if (!rowMap || !isCRDTMap(rowMap)) return null; + return getCellTextFromRow(rowMap, col); + }, +}; +} + +export function applyEditorOps(editor: EditorImplRuntime, ops: DocumentOp[], options?: ApplyOptions): void { + const self = editor as EditorImplRuntime; +const origin = options?.origin ?? "user"; +const groupId = getApplyOptionsGroupId(origin, options); +const undo = self._slots.get("undo:manager") as UndoManager | undefined; + +undo?.syncExplicitUndoGroup(groupId ?? null); + +if (options?.undoGroup && !groupId) { + undo?.stopCapturing(); +} + +self._pipeline.apply(ops, origin); +self._recordMutationGroupMetadata(origin, groupId); +} + +export function recordMutationGroupMetadata(editor: EditorImplRuntime, + origin: OpOrigin, + groupId: string | undefined, +): void { + const self = editor as EditorImplRuntime; +if (!groupId) { + return; +} +const controller = self._slots.get( + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, +) as + | { + setCurrentEntryMetadata( + key: string, + value: { before: T | null; after: T | null }, + ): boolean; + } + | undefined; +controller?.setCurrentEntryMetadata( + MUTATION_GROUP_METADATA_KEY, + { + before: null, + after: createMutationGroupMetadata(origin, groupId), + }, +); +} + +export function loadEditorDocument(editor: EditorImplRuntime, doc: CRDTDocument): void { + const self = editor as EditorImplRuntime; +self._queueExtensionLifecycle(async () => { + await self._extensions.deactivateAll(self); + if (self._isDestroyed) { + return; + } + self._teardownObservation(); + self._releaseSession?.(); + self._releaseSession = null; + self._bindSession( + createDocumentSession({ + adapter: self._adapter, + document: doc, + destroyWhenIdle: true, + ownsDocuments: false, + }), + ); + await self._rebindActiveScope(); +}); +} + +export function* iterateBlocks(editor: EditorImplRuntime, type?: string): Iterable { + const self = editor as EditorImplRuntime; +for (let i = 0; i < self._doc.blockOrder.length; i++) { + const id = (self._doc.blockOrder as CRDTArray).get( + i, + ) as string; + if (type) { + const blockMap = (self._doc.blocks as CRDTBlockMap).get(id); + if (!blockMap || blockMap.get("type") !== type) continue; + } + yield createBlockHandle( + id, + self._doc, + self._crdtDoc, + self._registry, + ); +} +} + +export function getEditorBlock(editor: EditorImplRuntime, blockId: string): BlockHandle | null { + const self = editor as EditorImplRuntime; +if (!(self._doc.blocks as CRDTBlockMap).has(blockId)) return null; +return createBlockHandle( + blockId, + self._doc, + self._crdtDoc, + self._registry, +); +} + +export function getFirstBlock(editor: EditorImplRuntime, ): BlockHandle | null { + const self = editor as EditorImplRuntime; +if (self._doc.blockOrder.length === 0) return null; +const id = (self._doc.blockOrder as CRDTArray).get(0) as string; +return createBlockHandle(id, self._doc, self._crdtDoc, self._registry); +} + +export function getLastBlock(editor: EditorImplRuntime, ): BlockHandle | null { + const self = editor as EditorImplRuntime; +const len = self._doc.blockOrder.length; +if (len === 0) return null; +const id = (self._doc.blockOrder as CRDTArray).get( + len - 1, +) as string; +return createBlockHandle(id, self._doc, self._crdtDoc, self._registry); +} + +export function getBlockCount(editor: EditorImplRuntime, ): number { + const self = editor as EditorImplRuntime; +return self._doc.blockOrder.length; +} + +export function getEditorBlockRevision(editor: EditorImplRuntime, blockId: string): number { + const self = editor as EditorImplRuntime; +return self._blockRevisions.get(blockId) ?? 0; +} + +export function destroyEditor(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._isDestroyed) { + return; +} +self._isDestroyed = true; +self._queueExtensionLifecycle(async () => { + await self._extensions.deactivateAll(self); + self._teardownObservation(); + self._releaseSession?.(); + self._releaseSession = null; + self._emitter.removeAllListeners(); +}); +} diff --git a/packages/core/src/editor/editorLifecycle.ts b/packages/core/src/editor/editorLifecycle.ts new file mode 100644 index 0000000..135b200 --- /dev/null +++ b/packages/core/src/editor/editorLifecycle.ts @@ -0,0 +1,354 @@ +import type { EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; +import { undoExtension } from "@pen/undo"; +import { documentOpsExtension } from "@pen/document-ops"; +import { deltaStreamExtension } from "@pen/delta-stream"; +import { richTextShortcutsExtension } from "@pen/shortcuts"; +import { SchemaEngineImpl } from "../schema/normalize"; +import { createBlockHandle } from "../schema/handles"; +import { resolveCellSelectionMatrix } from "./cellSelection"; +import { filterOpsForDocumentProfile } from "./profilePolicy"; +import type { CRDTUnknownMap } from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { DocumentStateImpl } from "./documentState"; +import { createDocumentSession } from "./documentSession"; + +type EditorImplRuntime = any; +type CRDTBlockMap = CRDTMap>; +type RawPenDocumentLike = { getArray?(name: "blockOrder"): CRDTArray; getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; blockOrder?: CRDTArray; blocks?: CRDTMap; apps?: CRDTMap; metadata?: CRDTMap; }; +function createGeneratedBlockId(): string { return crypto.randomUUID(); } +function missingPenDocumentRoot(name: string): never { throw new Error(`CRDT document is missing required Pen root "${name}".`); } +let hasWarnedAboutWithoutOption = false; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; + + +export function createPenDocumentForEditor(editor: EditorImplRuntime, crdtDoc: CRDTDocument): PenDocument { + const self = editor as EditorImplRuntime; +const wrapped = crdtDoc as CRDTDocument & { penDocument?: PenDocument }; +if (wrapped.penDocument) { + return wrapped.penDocument; +} + +const raw = (self._adapter.raw as (doc: CRDTDocument) => T)(crdtDoc); +const blockOrder = + (raw.getArray ? raw.getArray("blockOrder") : raw.blockOrder) ?? + missingPenDocumentRoot("blockOrder"); +const blocks = + (raw.getMap ? raw.getMap("blocks") : raw.blocks) ?? + missingPenDocumentRoot("blocks"); +const apps = + (raw.getMap ? raw.getMap("apps") : raw.apps) ?? + missingPenDocumentRoot("apps"); +const metadata = + (raw.getMap ? raw.getMap("metadata") : raw.metadata) ?? + missingPenDocumentRoot("metadata"); +return { + blockOrder, + blocks, + apps, + metadata, + adapter: self._adapter, +}; +} + +export function resolveEditorExtensions(editor: EditorImplRuntime, options: CreateEditorOptions): Extension[] { + const self = editor as EditorImplRuntime; +const without = new Set(options.without ?? []); +if (without.size > 0 && !hasWarnedAboutWithoutOption) { + hasWarnedAboutWithoutOption = true; + console.warn( + "Pen: createEditor({ without }) is deprecated. Prefer createEditor({ preset: defaultPreset(...) }) for default feature composition.", + ); +} +const defaultExtensions = options.preset?.resolve({ + schema: self._registry, + documentProfile: self._documentProfile, +}).extensions ?? [ + documentOpsExtension(), + deltaStreamExtension(), + undoExtension(), + richTextShortcutsExtension(), +]; +const defaults = defaultExtensions.filter( + (ext) => !without.has(ext.name), +); + +const userExtensions = options.extensions ?? []; +return [...defaults, ...userExtensions]; +} + +export function installProfilePolicyHook(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +self._pipeline.setFinalBeforeApplyHook((ops: DocumentOp[]) => + self._enforceDocumentProfileBoundary(ops), +); +} + +export function enforceDocumentProfileBoundary(editor: EditorImplRuntime, ops: DocumentOp[]): DocumentOp[] { + const self = editor as EditorImplRuntime; +const result = filterOpsForDocumentProfile( + ops, + self._documentProfile, + self._registry, +); + +for (const violation of result.violations) { + self._emitter.emit("diagnostic", { + code: "PEN_PROFILE_001", + level: "warn", + source: "profile-policy", + message: + `profile-policy: dropped ${violation.op.type} for disallowed ` + + `block type "${violation.blockType}" in ${violation.documentProfile} documents`, + remediation: + "Use a block type allowed by the active documentProfile or " + + "change the documentProfile before applying structural mutations.", + op: violation.op, + blockType: violation.blockType, + documentProfile: violation.documentProfile, + }); +} + +return result.ops; +} + +export function refreshCoreSlots(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +self._slots.set("core:engine", self._engine); +self._slots.set( + AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, + () => self._extensionLifecycle, +); +self._slots.set( + COLLECT_KEY_BINDINGS_SLOT_KEY, + (registry: SchemaRegistry) => + self._extensions.collectKeyBindings(registry), +); +} + +export function bindEditorSession(editor: EditorImplRuntime, session: DocumentSession, scopeId?: string): void { + const self = editor as EditorImplRuntime; +self._bindScope(session, scopeId); +self._releaseSession = session.attachEditor({ + onScopeReplaced: (event) => { + self._handleScopeReplacement(session, event); + }, +}); +} + +export function bindEditorScope(editor: EditorImplRuntime, session: DocumentSession, scopeId?: string): void { + const self = editor as EditorImplRuntime; +self._documentSession = session; +const scope = + (scopeId ? session.getScope(scopeId) : null) ?? session.rootScope; +self._documentScope = scope; +self._crdtDoc = scope.doc; +self._doc = self._createPenDocument(scope.doc); +self._awareness = session.getAwareness(scope.id); +} + +export function handleEditorScopeReplacement(editor: EditorImplRuntime, + session: DocumentSession, + event: DocumentScopeReplacementEvent, +): void { + const self = editor as EditorImplRuntime; +if (event.previousScope.id !== self._documentScope.id) { + return; +} +self._queueExtensionLifecycle(async () => { + await self._extensions.deactivateAll(self); + if (self._isDestroyed) { + return; + } + self._teardownObservation(); + self._bindScope(session, event.scope.id); + await self._rebindActiveScope(); +}); +} + +export function resolveEditorDocumentProfile(editor: EditorImplRuntime, + requestedProfile?: DocumentProfile, +): DocumentProfile { + const self = editor as EditorImplRuntime; +const persistedProfile = + self._adapter.getDocumentProfile?.(self._crdtDoc) ?? null; +const resolvedProfile = + persistedProfile ?? requestedProfile ?? "structured"; +if (persistedProfile == null) { + self._adapter.setDocumentProfile?.(self._crdtDoc, resolvedProfile); +} +return resolvedProfile; +} + +export async function rebindActiveScope(editor: EditorImplRuntime, ): Promise { + const self = editor as EditorImplRuntime; +self._documentProfile = self._resolveDocumentProfile(); +self._editorViewMode = + self._explicitEditorViewMode ?? self._documentProfile; +self._clientId = self._adapter.getClientId(self._crdtDoc); + +self._engine = new SchemaEngineImpl( + self._registry, + self._doc, + self._crdtDoc, +); +self._selection.updateDocument(self._doc, self._crdtDoc); +self._pipeline.updateDocument(self._doc, self._crdtDoc, self._engine); +self._documentState.updateDocument( + self._doc, + self._crdtDoc, + self._documentProfile, +); +self._pipeline._init((event: CRDTEvent) => { + self._dispatchCRDTEvent(event); +}); +self._refreshCoreSlots(); + +self._wireObservation(); +await self._activateExtensions(); +self._engine.normalizeAll(); +self._refreshDecorations(); +} + +export function refreshUndoManager(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +const slotUndo = self._slots.get("undo:manager") as + | UndoManager + | undefined; +(self as { undoManager: UndoManager }).undoManager = + slotUndo ?? NOOP_UNDO; +} + +export async function activateEditorExtensions(editor: EditorImplRuntime, ): Promise { + const self = editor as EditorImplRuntime; +const activation = self._extensions.activateAll(self); +self._refreshUndoManager(); +await activation; +self._refreshUndoManager(); +} + +export function queueExtensionLifecycle(editor: EditorImplRuntime, task: () => Promise): void { + const self = editor as EditorImplRuntime; +const runTask = async (): Promise => { + try { + await task(); + } catch (error) { + if (self._isDestroyed) { + return; + } + self._emitter.emit("diagnostic", { + code: "PEN_EXT_006", + level: "error", + source: "extension", + message: "Editor extension lifecycle transition failed", + remediation: + "Inspect async extension activate/deactivate hooks involved in document reload or scope replacement and ensure they resolve safely.", + error, + }); + } +}; + +self._extensionLifecycle = self._extensionLifecycle.then( + runTask, + runTask, +); +} + +export function ensureInitialParagraph(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._doc.blockOrder.length > 0) { + return; +} + +self.apply( + [ + { + type: "insert-block", + blockId: createGeneratedBlockId(), + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "system" }, +); +} + +export function createCommitEvent(editor: EditorImplRuntime, event: CRDTEvent): DocumentCommitEvent { + const self = editor as EditorImplRuntime; +const blockRevisions: Record = {}; +for (const blockId of event.affectedBlocks) { + const nextRevision = (self._blockRevisions.get(blockId) ?? 0) + 1; + self._blockRevisions.set(blockId, nextRevision); + blockRevisions[blockId] = nextRevision; +} +self._commitId += 1; +return { + commitId: self._commitId, + ops: event.ops, + origin: event.origin, + affectedBlocks: [...event.affectedBlocks], + blockRevisions, + scope: self._documentScope, +}; +} + +export function dispatchCRDTEvent(editor: EditorImplRuntime, event: CRDTEvent): void { + const self = editor as EditorImplRuntime; +self._syncDocumentProfileFromStorage(); +const commitEvent = self._createCommitEvent(event); +self._documentState.incrementalUpdate(event.affectedBlocks); +self._extensions.dispatchObserve([event], self); +const previousDecorationGeneration = self._decorations.generation; +const nextDecorations = self._refreshDecorations(); +if (nextDecorations.generation !== previousDecorationGeneration) { + self._emitter.emit("decorationsChange", nextDecorations.generation); +} +self._emitter.emit("change", [event]); +self._emitter.emit("documentCommit", commitEvent); +} + +export function syncDocumentProfileFromStorage(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +const persistedProfile = + self._adapter.getDocumentProfile?.(self._crdtDoc) ?? null; +if (!persistedProfile || persistedProfile === self._documentProfile) { + return; +} + +self._documentProfile = persistedProfile; +if (self._explicitEditorViewMode == null) { + self._editorViewMode = persistedProfile; +} +self._documentState.setDocumentProfile(persistedProfile); +} + +export function wireEditorObservation(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._documentSession) { + self._unsubObserve = self._documentSession.observe( + self._documentScope.id, + (event: CRDTEvent) => { + if (self._pipeline.suppressObserver) return; + self._dispatchCRDTEvent(event); + }, + ); + return; +} + +self._unsubObserve = self._adapter.observe( + self._crdtDoc, + (event: CRDTEvent) => { + if (self._pipeline.suppressObserver) return; + self._dispatchCRDTEvent(event); + }, +); +} + +export function teardownEditorObservation(editor: EditorImplRuntime, ): void { + const self = editor as EditorImplRuntime; +if (self._unsubObserve) { + self._unsubObserve(); + self._unsubObserve = null; +} +} diff --git a/packages/core/src/editor/editorSelectionMutations.ts b/packages/core/src/editor/editorSelectionMutations.ts new file mode 100644 index 0000000..0432834 --- /dev/null +++ b/packages/core/src/editor/editorSelectionMutations.ts @@ -0,0 +1,397 @@ +import type { EditorInternals, CreateEditorOptions, PenEventMap, DocumentCommitEvent, CRDTAdapter, CRDTDocument, CRDTEvent, PenDocument, SchemaRegistry, Awareness, DocumentSession, DocumentScope, DocumentScopeReplacementEvent, DocumentProfile, Extension, DocumentOp, ApplyOptions, OpOrigin, MutationGroupMetadata, SelectionState, TextSelection, DocumentRange, BlockHandle, Block, DocumentState, UndoManager, Unsubscribe, CRDTMap, CRDTArray, Position, DecorationSet, EditorViewMode } from "@pen/types"; +import { AWAIT_EXTENSION_LIFECYCLE_SLOT_KEY, COLLECT_KEY_BINDINGS_SLOT_KEY, usesInlineTextSelection, createMutationGroupMetadata, getApplyOptionsGroupId, MUTATION_GROUP_METADATA_KEY, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY } from "@pen/types"; +import { undoExtension } from "@pen/undo"; +import { documentOpsExtension } from "@pen/document-ops"; +import { deltaStreamExtension } from "@pen/delta-stream"; +import { richTextShortcutsExtension } from "@pen/shortcuts"; +import { SchemaEngineImpl } from "../schema/normalize"; +import { createBlockHandle } from "../schema/handles"; +import { resolveCellSelectionMatrix } from "./cellSelection"; +import { filterOpsForDocumentProfile } from "./profilePolicy"; +import type { CRDTUnknownMap } from "./crdtShapes"; +import { getTextProp, getTableContent, getCellText as getCellTextFromRow, isCRDTMap } from "./crdtShapes"; +import { DocumentStateImpl } from "./documentState"; +import { createDocumentSession } from "./documentSession"; + +type EditorImplRuntime = any; +type CRDTBlockMap = CRDTMap>; +type RawPenDocumentLike = { getArray?(name: "blockOrder"): CRDTArray; getMap?(name: "blocks" | "apps" | "metadata"): CRDTMap; blockOrder?: CRDTArray; blocks?: CRDTMap; apps?: CRDTMap; metadata?: CRDTMap; }; +function createGeneratedBlockId(): string { return crypto.randomUUID(); } +function missingPenDocumentRoot(name: string): never { throw new Error(`CRDT document is missing required Pen root "${name}".`); } +let hasWarnedAboutWithoutOption = false; +const NOOP_UNDO: UndoManager = { undo: () => false, redo: () => false, canUndo: () => false, canRedo: () => false, stopCapturing: () => {}, syncExplicitUndoGroup: () => {}, setGroupTimeout: () => {}, registerTrackedOrigins: () => () => {}, onStackChange: () => () => {} }; + + +export function replaceEditorSelection(editor: EditorImplRuntime, content: string | Block[]): void { + const self = editor as EditorImplRuntime; +const sel = self._selection.getSelection(); +if (!sel) return; + +if (sel.type === "text") { + const range = self._getSelectionRange(sel); + if (range.isMultiBlock) { + if (typeof content === "string") { + self._replaceMultiBlockTextRange(range, content); + } + return; + } + + const from = range.start.offset; + const to = range.end.offset; + const ops: DocumentOp[] = []; + if (to > from) { + ops.push({ + type: "delete-text", + blockId: range.start.blockId, + offset: from, + length: to - from, + }); + } + if (typeof content === "string" && content.length > 0) { + ops.push({ + type: "insert-text", + blockId: range.start.blockId, + offset: from, + text: content, + }); + } + if (ops.length > 0) { + self.apply(ops); + } + const nextOffset = + typeof content === "string" ? from + content.length : from; + self._collapseToPoint({ + blockId: range.start.blockId, + offset: nextOffset, + }); + return; +} + +if (sel.type === "block" && sel.blockIds.length > 0) { + const firstId = sel.blockIds[0]; + const firstIndex = self._pipeline._resolvePosition({ + before: firstId, + }); + const ops: DocumentOp[] = []; + + for (const id of sel.blockIds) { + ops.push({ type: "delete-block", blockId: id }); + } + + const insertPosition: Position = + firstIndex === 0 + ? "first" + : { + after: ( + self._doc.blockOrder as CRDTArray + ).get(firstIndex - 1) as string, + }; + + if (typeof content === "string") { + const newId = createGeneratedBlockId(); + ops.push({ + type: "insert-block", + blockId: newId, + blockType: "paragraph", + props: {}, + position: insertPosition, + }); + if (content.length > 0) { + ops.push({ + type: "insert-text", + blockId: newId, + offset: 0, + text: content, + }); + } + } else if (Array.isArray(content)) { + let prevPosition = insertPosition; + for (const block of content) { + const newId = createGeneratedBlockId(); + ops.push({ + type: "insert-block", + blockId: newId, + blockType: block.type, + props: block.props ?? {}, + position: prevPosition, + }); + if ( + typeof block.content === "string" && + block.content.length > 0 + ) { + ops.push({ + type: "insert-text", + blockId: newId, + offset: 0, + text: block.content, + }); + } + prevPosition = { after: newId }; + } + } + + self.apply(ops); +} +} + +export function deleteEditorSelection(editor: EditorImplRuntime, options?: ApplyOptions): void { + const self = editor as EditorImplRuntime; +const sel = self._selection.getSelection(); +if (!sel) return; + +if (sel.type === "text") { + const range = self._getSelectionRange(sel); + if (range.isMultiBlock) { + self._deleteMultiBlockTextRange(range, options); + return; + } + + if ( + !self._usesInlineTextSelection(range.start.blockId) && + self._isWholeBlockSelection( + range.start.blockId, + range.start.offset, + range.end.offset, + ) + ) { + self.apply( + [ + { + type: "delete-block", + blockId: range.start.blockId, + }, + ], + options, + ); + self.setSelection(null); + return; + } + + const from = range.start.offset; + const to = range.end.offset; + if (to > from) { + self.apply( + [ + { + type: "delete-text", + blockId: range.start.blockId, + offset: from, + length: to - from, + }, + ], + options, + ); + } + self._collapseToPoint({ + blockId: range.start.blockId, + offset: from, + }); + return; +} + +if (sel.type === "block") { + const ops: DocumentOp[] = sel.blockIds.map((id: string) => ({ + type: "delete-block" as const, + blockId: id, + })); + self.apply(ops, options); + self.setSelection(null); +} + +if (sel.type === "cell") { + const block = self.getBlock(sel.blockId); + if (!block) return; + const ops: DocumentOp[] = []; + for (const rowCells of resolveCellSelectionMatrix(block, sel)) { + for (const cellCoord of rowCells) { + const cell = block.tableCell(cellCoord.row, cellCoord.col); + if (!cell) continue; + const len = cell.length(); + if (len > 0) { + ops.push({ + type: "delete-table-cell-text", + blockId: sel.blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: 0, + length: len, + } as DocumentOp); + } + } + } + if (ops.length > 0) { + self.apply(ops, options); + } + self.setSelection({ + ...sel, + head: sel.anchor, + }); +} +} + +export function getTextForBlock(editor: EditorImplRuntime, blockId: string): string { + const self = editor as EditorImplRuntime; +return self.getBlock(blockId)?.textContent() ?? ""; +} + +export function getSelectionRange(editor: EditorImplRuntime, sel: TextSelection): DocumentRange { + const self = editor as EditorImplRuntime; +return sel.toRange(); +} + +export function usesInlineTextSelectionForBlock(editor: EditorImplRuntime, blockId: string): boolean { + const self = editor as EditorImplRuntime; +const block = self.getBlock(blockId); +if (!block) { + return false; +} + +const schema = self._registry.resolve(block.type); +if (!schema) { + return false; +} + +return usesInlineTextSelection(schema); +} + +export function getBlockSelectionSpan(editor: EditorImplRuntime, blockId: string): number { + const self = editor as EditorImplRuntime; +if (self._usesInlineTextSelection(blockId)) { + return self._getTextForBlock(blockId).length; +} +return self.getBlock(blockId) ? 1 : 0; +} + +export function isWholeBlockSelection(editor: EditorImplRuntime, + blockId: string, + startOffset: number, + endOffset: number, +): boolean { + const self = editor as EditorImplRuntime; +const span = self._getBlockSelectionSpan(blockId); +if (span <= 0) { + return false; +} +return startOffset <= 0 && endOffset >= span; +} + +export function collapseToPoint(editor: EditorImplRuntime, point: { blockId: string; offset: number }): void { + const self = editor as EditorImplRuntime; + self.selectTextRange(point, point); +} + +export function sliceInlineDeltas( + editor: EditorImplRuntime, + blockId: string, + startOffset: number, +): Array<{ insert: string; attributes?: Record }> { + const self = editor as EditorImplRuntime; + const handle = self.getBlock(blockId); + if (!handle) return []; + const deltas = handle.textDeltas().filter((delta: { insert: string }) => delta.insert !== "\u200B"); + const sliced: Array<{ insert: string; attributes?: Record }> = []; + let offset = 0; + for (const delta of deltas) { + const length = delta.insert.length; + if (startOffset >= offset + length) { + offset += length; + continue; + } + const localStart = Math.max(0, startOffset - offset); + const text = delta.insert.slice(localStart); + if (text.length > 0) { + sliced.push({ insert: text, ...(delta.attributes ? { attributes: delta.attributes } : {}) }); + } + offset += length; + } + return sliced; +} + +export function buildMultiBlockTextReplacement( + editor: EditorImplRuntime, + range: DocumentRange, + insertedText: string, +): { ops: DocumentOp[]; caret: { blockId: string; offset: number } } { + const self = editor as EditorImplRuntime; + const startId = range.start.blockId; + const endId = range.end.blockId; + const startText = self._getTextForBlock(startId); + const middleIds = range.blockRange.slice(1, -1); + const suffixDeltas = self._sliceInlineDeltas(endId, range.end.offset); + const ops: DocumentOp[] = []; + if (range.start.offset < startText.length) { + ops.push({ type: "delete-text", blockId: startId, offset: range.start.offset, length: startText.length - range.start.offset }); + } + if (range.end.offset > 0) { + ops.push({ type: "delete-text", blockId: endId, offset: 0, length: range.end.offset }); + } + for (const blockId of middleIds) ops.push({ type: "delete-block", blockId }); + let insertionOffset = range.start.offset; + if (insertedText.length > 0) { + ops.push({ type: "insert-text", blockId: startId, offset: insertionOffset, text: insertedText }); + insertionOffset += insertedText.length; + } + for (const delta of suffixDeltas) { + ops.push({ type: "insert-text", blockId: startId, offset: insertionOffset, text: delta.insert, marks: delta.attributes }); + insertionOffset += delta.insert.length; + } + ops.push({ type: "delete-block", blockId: endId }); + return { ops, caret: { blockId: startId, offset: range.start.offset + insertedText.length } }; +} + +export function deleteMultiBlockTextRange( + editor: EditorImplRuntime, + range: DocumentRange, + options?: ApplyOptions, +): { blockId: string; offset: number } | null { + const self = editor as EditorImplRuntime; + const startId = range.start.blockId; + const endId = range.end.blockId; + if (startId === endId) { + const from = range.start.offset; + const to = range.end.offset; + if (to > from) self.apply([{ type: "delete-text", blockId: startId, offset: from, length: to - from }], options); + const caret = { blockId: startId, offset: from }; + self._collapseToPoint(caret); + return caret; + } + const startInline = self._usesInlineTextSelection(startId); + const endInline = self._usesInlineTextSelection(endId); + if (startInline && endInline) { + const { ops, caret } = self._buildMultiBlockTextReplacement(range, ""); + self.apply(ops, options); + self._collapseToPoint(caret); + return caret; + } + const middleIds = range.blockRange.slice(1, -1); + const ops: DocumentOp[] = []; + if (startInline) { + const startText = self._getTextForBlock(startId); + if (range.start.offset < startText.length) ops.push({ type: "delete-text", blockId: startId, offset: range.start.offset, length: startText.length - range.start.offset }); + } else if (self._isWholeBlockSelection(startId, range.start.offset, self._getBlockSelectionSpan(startId))) { + ops.push({ type: "delete-block", blockId: startId }); + } + for (const blockId of middleIds) ops.push({ type: "delete-block", blockId }); + if (endInline) { + if (range.end.offset > 0) ops.push({ type: "delete-text", blockId: endId, offset: 0, length: range.end.offset }); + } else if (self._isWholeBlockSelection(endId, 0, range.end.offset)) { + ops.push({ type: "delete-block", blockId: endId }); + } + if (ops.length > 0) self.apply(ops, options); + const caret = startInline ? { blockId: startId, offset: range.start.offset } : endInline ? { blockId: endId, offset: 0 } : null; + if (caret) self._collapseToPoint(caret); + else self.setSelection(null); + return caret; +} + +export function replaceMultiBlockTextRange( + editor: EditorImplRuntime, + range: DocumentRange, + text: string, +): { blockId: string; offset: number } { + const self = editor as EditorImplRuntime; + const { ops, caret } = self._buildMultiBlockTextReplacement(range, text); + self.apply(ops); + self._collapseToPoint(caret); + return caret; +} diff --git a/packages/core/src/editor/inlineCompletion.ts b/packages/core/src/editor/inlineCompletion.ts index 32f141c..9594ac5 100644 --- a/packages/core/src/editor/inlineCompletion.ts +++ b/packages/core/src/editor/inlineCompletion.ts @@ -1,4 +1,5 @@ import { + INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE, INLINE_COMPLETION_SLOT, type Decoration, type Editor, @@ -58,7 +59,14 @@ class InlineCompletionControllerImpl implements InlineCompletionController { visibleSuggestion: null, }; + if (suggestion.accept) { + const accepted = suggestion.accept(this._editor, suggestion); + this._emit(); + return accepted; + } + if (suggestion.type === "inline") { + const nextOffset = suggestion.offset + suggestion.text.length; this._editor.apply( [{ type: "insert-text", @@ -68,6 +76,7 @@ class InlineCompletionControllerImpl implements InlineCompletionController { }], { origin: "ai", undoGroup: true }, ); + this._editor.selectText(suggestion.blockId, nextOffset, nextOffset); this._emit(); return true; } @@ -91,6 +100,11 @@ class InlineCompletionControllerImpl implements InlineCompletionController { ], { origin: "ai", undoGroup: true }, ); + this._editor.selectText( + blockId, + suggestion.text.length, + suggestion.text.length, + ); this._emit(); return true; } @@ -101,26 +115,39 @@ class InlineCompletionControllerImpl implements InlineCompletionController { buildDecorations(): readonly Decoration[] { const suggestion = this._state.visibleSuggestion; - if (!suggestion || suggestion.type !== "inline") { + if (!suggestion) { return []; } + const blockDecoration: Decoration = { + type: "block", + blockId: suggestion.blockId, + attributes: { + [INLINE_COMPLETION_VISIBLE_BLOCK_ATTRIBUTE]: true, + }, + }; + if (suggestion.type !== "inline") { + return [blockDecoration]; + } const anchor = resolveInlineSuggestionAnchor(this._editor, suggestion); if (!anchor) { - return []; + return [blockDecoration]; } - return [{ - type: "inline", - blockId: suggestion.blockId, - from: anchor.from, - to: anchor.to, - attributes: { - class: "pen-ephemeral-suggestion", - "data-suggestion-id": suggestion.id, - "data-suggestion-text": suggestion.text, - "data-suggestion-type": suggestion.type, - "data-suggestion-placement": anchor.placement, + return [ + blockDecoration, + { + type: "inline", + blockId: suggestion.blockId, + from: anchor.from, + to: anchor.to, + attributes: { + class: "pen-ephemeral-suggestion", + "data-suggestion-id": suggestion.id, + "data-suggestion-text": suggestion.text, + "data-suggestion-type": suggestion.type, + "data-suggestion-placement": anchor.placement, + }, }, - }]; + ]; } destroy(): void { diff --git a/packages/core/src/editor/tableGridCellHelpers.ts b/packages/core/src/editor/tableGridCellHelpers.ts new file mode 100644 index 0000000..a6578f4 --- /dev/null +++ b/packages/core/src/editor/tableGridCellHelpers.ts @@ -0,0 +1,128 @@ +import type { TableColumnSchema } from "@pen/types"; +import { + type CRDTTextLike, + type CRDTUnknownMap, + getCellText, + getRowCells, + getStringProp, + isCRDTMap, +} from "./crdtShapes"; + +export type CRDTDelta = { + insert: string | Record; + attributes?: Record; +}; + +export type TableRowSnapshot = { + rowId?: string; + cells: Array<{ + cellId?: string; + deltas: CRDTDelta[]; + }>; +}; + +type CRDTTextWithDelta = CRDTTextLike & { + toDelta?: () => CRDTDelta[]; + insertEmbed?: (offset: number, value: Record) => void; +}; + +export function getCellContent( + rowMap: CRDTUnknownMap, + columnIndex: number, +): CRDTTextLike | null { + return getCellText(rowMap, columnIndex); +} + +export function ensureCellContent( + rowMap: CRDTUnknownMap, + columnIndex: number, + createCell: () => CRDTUnknownMap, +): CRDTTextLike | null { + const cells = getRowCells(rowMap); + if (!cells || columnIndex < 0) { + return null; + } + while (cells.length <= columnIndex) { + cells.insert(cells.length, [createCell()]); + } + return getCellText(rowMap, columnIndex); +} + +export function captureTableRowSnapshot( + sourceRow: CRDTUnknownMap, +): TableRowSnapshot { + const sourceCells = getRowCells(sourceRow); + const snapshot: TableRowSnapshot = { + rowId: getStringProp(sourceRow, "id"), + cells: [], + }; + if (!sourceCells) { + return snapshot; + } + + for (let columnIndex = 0; columnIndex < sourceCells.length; columnIndex++) { + const sourceCell = sourceCells.get(columnIndex); + if (!sourceCell || !isCRDTMap(sourceCell)) { + snapshot.cells.push({ deltas: [] }); + continue; + } + snapshot.cells.push({ + cellId: getStringProp(sourceCell, "id"), + deltas: readTableCellDeltas(sourceCell), + }); + } + + return snapshot; +} + +export function writeCellDeltas( + cellMap: CRDTUnknownMap, + deltas: CRDTDelta[], +): void { + const targetContent = cellMap.get("content") as CRDTTextWithDelta | undefined; + if (!targetContent) { + return; + } + + let offset = 0; + for (const delta of deltas) { + if (typeof delta.insert === "string") { + if (delta.insert.length > 0) { + targetContent.insert(offset, delta.insert, delta.attributes); + offset += delta.insert.length; + } + continue; + } + + if (typeof targetContent.insertEmbed === "function") { + targetContent.insertEmbed(offset, delta.insert); + if (delta.attributes) { + targetContent.format(offset, 1, delta.attributes); + } + offset += 1; + } + } +} + +export function readTableCellDeltas(cellMap: CRDTUnknownMap): CRDTDelta[] { + const sourceContent = cellMap.get("content") as CRDTTextWithDelta | undefined; + if (!sourceContent) { + return []; + } + return typeof sourceContent.toDelta === "function" + ? sourceContent.toDelta() + : [{ insert: sourceContent.toString() }]; +} + +export function createRecordMap( + createMap: () => CRDTUnknownMap, + record: TableColumnSchema | Record, +): CRDTUnknownMap { + const map = createMap(); + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) { + map.set(key, value); + } + } + return map; +} diff --git a/packages/core/src/editor/tableGridExecutor.ts b/packages/core/src/editor/tableGridExecutor.ts index 42c049b..db331ba 100644 --- a/packages/core/src/editor/tableGridExecutor.ts +++ b/packages/core/src/editor/tableGridExecutor.ts @@ -13,16 +13,22 @@ import type { } from "@pen/types"; import { generateId } from "@pen/types"; import { - type CRDTTextLike, type CRDTUnknownArray, type CRDTUnknownMap, - getCellText, getRowCells, getStringProp, getTableColumns, getTableContent, isCRDTMap, } from "./crdtShapes"; +import { + captureTableRowSnapshot, + createRecordMap, + ensureCellContent, + getCellContent, + type TableRowSnapshot, + writeCellDeltas, +} from "./tableGridCellHelpers"; const ZERO_WIDTH_SPACE = "\u200B"; @@ -31,24 +37,6 @@ export type TableCellDelta = { attributes?: Record; }; -type CRDTDelta = { - insert: string | Record; - attributes?: Record; -}; - -type TableRowSnapshot = { - rowId?: string; - cells: Array<{ - cellId?: string; - deltas: CRDTDelta[]; - }>; -}; - -type CRDTTextWithDelta = CRDTTextLike & { - toDelta?: () => CRDTDelta[]; - insertEmbed?: (offset: number, value: Record) => void; -}; - export class TableGridExecutor { private readonly _adapter: CRDTAdapter; @@ -123,9 +111,10 @@ export class TableGridExecutor { break; case "insert-table-cell-text": { const cellOp = op as InsertTableCellTextOp; - const content = this._ensureCellContent( + const content = ensureCellContent( tableContent.get(cellOp.row), cellOp.col, + () => this.createTableCell(), ); if (content && typeof content.insert === "function") { content.insert(cellOp.offset, cellOp.text); @@ -134,7 +123,7 @@ export class TableGridExecutor { } case "delete-table-cell-text": { const cellOp = op as DeleteTableCellTextOp; - const content = this._getCellContent( + const content = getCellContent( tableContent.get(cellOp.row), cellOp.col, ); @@ -145,7 +134,7 @@ export class TableGridExecutor { } case "format-table-cell-text": { const cellOp = op as FormatTableCellTextOp; - const content = this._getCellContent( + const content = getCellContent( tableContent.get(cellOp.row), cellOp.col, ); @@ -211,7 +200,7 @@ export class TableGridExecutor { col: number, deltas: TableCellDelta[], ): void { - const content = getCellText(row, col); + const content = getCellContent(row, col); if (!content) { return; } @@ -222,7 +211,7 @@ export class TableGridExecutor { } readTableCellText(rowMap: CRDTUnknownMap, columnIndex: number): string { - const content = this._getCellContent(rowMap, columnIndex); + const content = getCellContent(rowMap, columnIndex); if (content && typeof content.toString === "function") { const text = content.toString(); return text === ZERO_WIDTH_SPACE ? "" : text; @@ -235,7 +224,9 @@ export class TableGridExecutor { columnIndex: number, value: string, ): void { - const content = this._ensureCellContent(rowMap, columnIndex); + const content = ensureCellContent(rowMap, columnIndex, () => + this.createTableCell(), + ); if (!content) { return; } @@ -310,28 +301,7 @@ export class TableGridExecutor { } captureTableRowSnapshot(sourceRow: CRDTUnknownMap): TableRowSnapshot { - const sourceCells = getRowCells(sourceRow); - const snapshot: TableRowSnapshot = { - rowId: getStringProp(sourceRow, "id"), - cells: [], - }; - if (!sourceCells) { - return snapshot; - } - - for (let columnIndex = 0; columnIndex < sourceCells.length; columnIndex++) { - const sourceCell = sourceCells.get(columnIndex); - if (!sourceCell || !isCRDTMap(sourceCell)) { - snapshot.cells.push({ deltas: [] }); - continue; - } - snapshot.cells.push({ - cellId: getStringProp(sourceCell, "id"), - deltas: this.readTableCellDeltas(sourceCell), - }); - } - - return snapshot; + return captureTableRowSnapshot(sourceRow); } applyTableRowSnapshot( @@ -363,7 +333,7 @@ export class TableGridExecutor { targetCell.set("id", cellSnapshot.cellId); } - this.writeCellDeltas(targetCell, cellSnapshot.deltas); + writeCellDeltas(targetCell, cellSnapshot.deltas); } } @@ -420,7 +390,10 @@ export class TableGridExecutor { optionsArray.insert( 0, value.map((option) => - this._createRecordMap(option as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + option as Record, + ), ), ); columnMap.set(key, optionsArray); @@ -429,7 +402,10 @@ export class TableGridExecutor { if (key === "format" && value && typeof value === "object") { columnMap.set( key, - this._createRecordMap(value as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + value as Record, + ), ); continue; } @@ -453,7 +429,10 @@ export class TableGridExecutor { optionsArray.insert( 0, value.map((option: unknown) => - this._createRecordMap(option as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + option as Record, + ), ), ); } @@ -463,7 +442,10 @@ export class TableGridExecutor { if (key === "format" && value && typeof value === "object") { columnMap.set( key, - this._createRecordMap(value as Record), + createRecordMap( + () => this._adapter.createMap() as CRDTUnknownMap, + value as Record, + ), ); return; } @@ -487,80 +469,4 @@ export class TableGridExecutor { } return ids; } - - private _getCellContent( - rowMap: CRDTUnknownMap, - columnIndex: number, - ): CRDTTextLike | null { - return getCellText(rowMap, columnIndex); - } - - private _ensureCellContent( - rowMap: CRDTUnknownMap, - columnIndex: number, - ): CRDTTextLike | null { - const cells = getRowCells(rowMap); - if (!cells || columnIndex < 0) { - return null; - } - while (cells.length <= columnIndex) { - cells.insert(cells.length, [this.createTableCell()]); - } - return getCellText(rowMap, columnIndex); - } - - private cloneTableCellContent( - sourceCell: CRDTUnknownMap, - targetCell: CRDTUnknownMap, - ): void { - this.writeCellDeltas(targetCell, this.readTableCellDeltas(sourceCell)); - } - - private readTableCellDeltas(cellMap: CRDTUnknownMap): CRDTDelta[] { - const sourceContent = cellMap.get("content") as CRDTTextWithDelta | undefined; - if (!sourceContent) { - return []; - } - return typeof sourceContent.toDelta === "function" - ? sourceContent.toDelta() - : [{ insert: sourceContent.toString() }]; - } - - private writeCellDeltas(cellMap: CRDTUnknownMap, deltas: CRDTDelta[]): void { - const targetContent = cellMap.get("content") as CRDTTextWithDelta | undefined; - if (!targetContent) { - return; - } - - let offset = 0; - for (const delta of deltas) { - if (typeof delta.insert === "string") { - if (delta.insert.length > 0) { - targetContent.insert(offset, delta.insert, delta.attributes); - offset += delta.insert.length; - } - continue; - } - - if (typeof targetContent.insertEmbed === "function") { - targetContent.insertEmbed(offset, delta.insert); - if (delta.attributes) { - targetContent.format(offset, 1, delta.attributes); - } - offset += 1; - } - } - } - - private _createRecordMap( - record: TableColumnSchema | Record, - ): CRDTUnknownMap { - const map = this._adapter.createMap() as CRDTUnknownMap; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) { - map.set(key, value); - } - } - return map; - } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3884cd8..3c031f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,56 +1,51 @@ import { - filterOpsForDocumentProfile, - filterPendingBlocksForDocumentProfile, - createImportResult, - isContinuousTextFlowCapability, - normalizePendingBlocksForImport, - reportPendingBlockImportViolations, - reportPendingBlockProfileViolations, - resolveBlockFlowCapability, - shouldAllowDirectBlockPaste, - shouldAllowFlowInsertionInSlashMenu, - shouldFallbackMixedSelectionToBlock, - shouldForceBlockScopedSelectAll, + filterOpsForDocumentProfile, + filterPendingBlocksForDocumentProfile, + createImportResult, + isContinuousTextFlowCapability, + normalizePendingBlocksForImport, + reportPendingBlockImportViolations, + reportPendingBlockProfileViolations, + resolveBlockFlowCapability, + shouldAllowDirectBlockPaste, + shouldAllowFlowInsertionInSlashMenu, + shouldFallbackMixedSelectionToBlock, + shouldForceBlockScopedSelectAll, } from "./editor/profilePolicy"; // Contracts live in @pen/types. // Keep @pen/core focused on runtime entrypoints and advanced internals. // Schema engine runtime -export { - SchemaRegistryImpl, - mergeSchemas, -} from "./schema/registry"; +export { SchemaRegistryImpl, mergeSchemas } from "./schema/registry"; export type { SchemaRegistryConfig } from "./schema/registry"; export { - SchemaEngineImpl, - deepEqual, - sortDeltaAttributes, + SchemaEngineImpl, + deepEqual, + sortDeltaAttributes, } from "./schema/normalize"; -export { - createBlockHandle, - createAppHandle, -} from "./schema/handles"; +export { createBlockHandle, createAppHandle } from "./schema/handles"; export { suggestion } from "./schema/system-marks/suggestion"; // Editor runtime -export { createEditor } from "./editor/editor"; +export { createEditor, createHeadlessEditor } from "./editor/editor"; +export type { CreateHeadlessEditorOptions } from "./editor/editor"; export { - createDocumentSession, - DocumentSessionImpl, + createDocumentSession, + DocumentSessionImpl, } from "./editor/documentSession"; export { EventEmitter } from "./editor/events"; export { - createDecorationSet, - emptyDecorationSet, - mergeDecorationSets, + createDecorationSet, + emptyDecorationSet, + mergeDecorationSets, } from "./editor/decorations"; export { - ensureInlineCompletionController, - getInlineCompletionController, + ensureInlineCompletionController, + getInlineCompletionController, } from "./editor/inlineCompletion"; export { DocumentStateImpl } from "./editor/documentState"; export { DocumentRangeImpl } from "./editor/range"; @@ -64,28 +59,31 @@ export { } from "./editor/cellSelection"; export { getNumberedListItemValue } from "./editor/orderedList"; export { - createImportResult, - filterOpsForDocumentProfile, - filterPendingBlocksForDocumentProfile, - isContinuousTextFlowCapability, - normalizePendingBlocksForImport, - reportPendingBlockImportViolations, - reportPendingBlockProfileViolations, - resolveBlockFlowCapability, - shouldAllowDirectBlockPaste, - shouldAllowFlowInsertionInSlashMenu, - shouldFallbackMixedSelectionToBlock, - shouldForceBlockScopedSelectAll, + createImportResult, + filterOpsForDocumentProfile, + filterPendingBlocksForDocumentProfile, + isContinuousTextFlowCapability, + normalizePendingBlocksForImport, + reportPendingBlockImportViolations, + reportPendingBlockProfileViolations, + resolveBlockFlowCapability, + shouldAllowDirectBlockPaste, + shouldAllowFlowInsertionInSlashMenu, + shouldFallbackMixedSelectionToBlock, + shouldForceBlockScopedSelectAll, }; export type { - PendingBlockImportPolicyViolation, - PendingBlockProfilePolicyViolation, - ProfilePolicyViolation, + PendingBlockImportPolicyViolation, + PendingBlockProfilePolicyViolation, + ProfilePolicyViolation, } from "./editor/profilePolicy"; // Importer utilities (used by Wave 4 importers) export { blocksToOps } from "./importerUtils"; -export type { PendingBlock, ImportOptions as ImporterOptions } from "./importerUtils"; +export type { + PendingBlock, + ImportOptions as ImporterOptions, +} from "./importerUtils"; // Exporter utilities (shared by Wave 4 exporters) export { buildTableChildren, buildDatabaseData } from "./exporterUtils"; diff --git a/packages/core/src/schema/appHandleImpl.ts b/packages/core/src/schema/appHandleImpl.ts new file mode 100644 index 0000000..4470944 --- /dev/null +++ b/packages/core/src/schema/appHandleImpl.ts @@ -0,0 +1,66 @@ +import type { + AppHandle, + AppPlacement, + BlockHandle, + CRDTDocument, + PenDocument, + SchemaRegistry, +} from "@pen/types"; +import { + crdtMapToPlainRecord, + getMapProp, + isCRDTMap, + type CRDTUnknownMap, +} from "../editor/crdtShapes"; + +type CreateBlockHandle = ( + blockId: string, + doc: PenDocument, + crdtDoc: CRDTDocument, + registry: SchemaRegistry, +) => BlockHandle; + +export class AppHandleImpl implements AppHandle { + constructor( + private readonly _id: string, + private readonly _doc: PenDocument, + private readonly _crdtDoc: CRDTDocument, + private readonly _registry: SchemaRegistry, + private readonly _createBlockHandle: CreateBlockHandle, + ) {} + + get id(): string { + return this._id; + } + + get type(): string { + return this.appMap.get("type") as string; + } + + get placement(): AppPlacement { + return this.appMap.get("placement") as AppPlacement; + } + + get config(): Readonly> { + return crdtMapToPlainRecord(getMapProp(this.appMap, "config")) ?? {}; + } + + get anchorBlock(): BlockHandle | null { + const placement = this.placement; + if (placement && "blockId" in placement && placement.blockId) { + return this._createBlockHandle( + placement.blockId as string, + this._doc, + this._crdtDoc, + this._registry, + ); + } + return null; + } + + private get appMap(): CRDTUnknownMap { + const map = this._doc.apps.get(this._id); + if (!isCRDTMap(map)) throw new Error(`App not found: ${this._id}`); + return map; + } +} diff --git a/packages/core/src/schema/handleValueHelpers.ts b/packages/core/src/schema/handleValueHelpers.ts new file mode 100644 index 0000000..ad9e704 --- /dev/null +++ b/packages/core/src/schema/handleValueHelpers.ts @@ -0,0 +1,229 @@ +import type { + DatabaseViewState, + InlineDelta, + InlineNodeDeltaInsert, + TableColumnSchema, +} from "@pen/types"; +import { + crdtMapToPlainRecord, + crdtValueToPlain, + getArrayProp, + getMapProp, + type CRDTTextLike, + type CRDTUnknownArray, + type CRDTUnknownMap, +} from "../editor/crdtShapes"; + +type TextDelta = { + insert: unknown; + attributes?: Record; +}; + +export function getMapEntries( + map: CRDTUnknownMap | null, +): Iterable<[string, unknown]> { + return map?.entries?.() ?? []; +} + +export function getChildrenArray( + blockMap: CRDTUnknownMap, +): CRDTUnknownArray | null { + return getArrayProp(blockMap, "children"); +} + +export function getPropsMap(blockMap: CRDTUnknownMap): CRDTUnknownMap | null { + return getMapProp(blockMap, "props"); +} + +export function getDeltaFragments(text: CRDTTextLike | null): TextDelta[] { + return typeof text?.toDelta === "function" ? text.toDelta() : []; +} + +function toInlineDeltaInsert(value: unknown): string | InlineNodeDeltaInsert { + if (typeof value === "string") { + return value; + } + if (!value || typeof value !== "object") { + return ""; + } + const record = value as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (!type) { + return ""; + } + const props: Record = {}; + for (const [key, entry] of Object.entries(record)) { + if (key === "type") { + continue; + } + props[key] = entry; + } + return { type, props }; +} + +export function toInlineDeltas(content: CRDTTextLike | null): InlineDelta[] { + if (typeof content?.toDelta !== "function") { + return []; + } + return getDeltaFragments(content).map((delta) => ({ + insert: toInlineDeltaInsert(delta.insert), + ...(delta.attributes ? { attributes: delta.attributes } : {}), + })); +} + +export function arrayValues(array: CRDTUnknownArray): T[] { + return ( + array.toArray?.() ?? + Array.from({ length: array.length }, (_, index) => array.get(index)) + ); +} + +export function resolveText(content: CRDTTextLike): string { + const deltas = getDeltaFragments(content); + let result = ""; + for (const d of deltas) { + if (typeof d.insert !== "string") continue; + const suggestion = d.attributes?.suggestion as + | { action?: string } + | undefined; + if (suggestion?.action === "delete") continue; + result += d.insert; + } + return result; +} + +export function toTableColumnSchema(column: unknown): TableColumnSchema | null { + if (!column || typeof column !== "object") return null; + const mapLike = column as { + get?: (key: string) => unknown; + entries?: () => IterableIterator<[string, unknown]>; + }; + const id = mapLike.get?.("id"); + const title = mapLike.get?.("title"); + const type = mapLike.get?.("type"); + if ( + typeof id !== "string" || + typeof title !== "string" || + typeof type !== "string" + ) { + return null; + } + const options = toPlainArray(mapLike.get?.("options")); + return { + id, + title, + type: type as TableColumnSchema["type"], + width: toNumber(mapLike.get?.("width")), + hidden: toBoolean(mapLike.get?.("hidden")), + pinned: toPinned(mapLike.get?.("pinned")), + options, + format: (toPlainObject(mapLike.get?.("format")) ?? + undefined) as TableColumnSchema["format"], + readonly: toBoolean(mapLike.get?.("readonly")), + }; +} + +export function toDatabaseViewState(view: unknown): DatabaseViewState | null { + if (!view || typeof view !== "object") return null; + const mapLike = view as { + get?: (key: string) => unknown; + }; + const id = mapLike.get?.("id"); + const type = mapLike.get?.("type"); + if (typeof id !== "string" || typeof type !== "string") { + return null; + } + + const filterValue = toPlainObject(mapLike.get?.("filter")); + + return { + id, + title: toString(mapLike.get?.("title")), + type: type as DatabaseViewState["type"], + visibleColumnIds: toStringArray(mapLike.get?.("visibleColumnIds")), + columnOrder: toStringArray(mapLike.get?.("columnOrder")), + sort: toPlainArray(mapLike.get?.("sort")) as DatabaseViewState["sort"], + filter: (filterValue as DatabaseViewState["filter"] | null) ?? null, + groupBy: toNullableString(mapLike.get?.("groupBy")), + rowPinning: toDatabaseRowPinning(mapLike.get?.("rowPinning")), + pageIndex: toNumber(mapLike.get?.("pageIndex")), + pageSize: toNumber(mapLike.get?.("pageSize")), + }; +} + +function toDatabaseRowPinning( + value: unknown, +): DatabaseViewState["rowPinning"] { + if (!value || typeof value !== "object") { + return undefined; + } + const mapLike = value as { + get?: (key: string) => unknown; + }; + const topValues = toStringArray(mapLike.get?.("top")); + const bottomValues = toStringArray(mapLike.get?.("bottom")); + const top = topValues && topValues.length > 0 ? topValues : undefined; + const bottom = + bottomValues && bottomValues.length > 0 ? bottomValues : undefined; + if (!top && !bottom) { + return undefined; + } + return { + top, + bottom, + }; +} + +function toPlainArray(value: unknown): TableColumnSchema["options"] { + if ( + !value || + typeof (value as { toArray?: () => unknown[] }).toArray !== "function" + ) { + return undefined; + } + const items = (value as { toArray: () => unknown[] }).toArray(); + return items + .map((item) => crdtValueToPlain(item)) + .filter((item): item is Record => item !== null) + .map( + (item) => + item as unknown as NonNullable[number], + ); +} + +function toPlainObject(value: unknown): Record | null { + return crdtMapToPlainRecord(value); +} + +function toNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function toString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function toNullableString(value: unknown): string | null | undefined { + if (value === null) return null; + return typeof value === "string" ? value : undefined; +} + +function toStringArray(value: unknown): string[] | undefined { + if ( + !value || + typeof (value as { toArray?: () => unknown[] }).toArray !== "function" + ) { + return undefined; + } + return (value as { toArray: () => unknown[] }) + .toArray() + .filter((entry): entry is string => typeof entry === "string"); +} + +function toBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function toPinned(value: unknown): "left" | "right" | undefined { + return value === "left" || value === "right" ? value : undefined; +} diff --git a/packages/core/src/schema/handles.ts b/packages/core/src/schema/handles.ts index ab65193..786d406 100644 --- a/packages/core/src/schema/handles.ts +++ b/packages/core/src/schema/handles.ts @@ -1,13 +1,12 @@ import type { + AppHandle, AppPlacement, BlockHandle, - AppHandle, + DatabaseViewState, InlineDelta, - InlineNodeDeltaInsert, TableCellHandle, TableColumnSchema, TableRowHandle, - DatabaseViewState, CRDTDocument, LayoutProps, PenDocument, @@ -15,10 +14,8 @@ import type { } from "@pen/types"; import { crdtMapToPlainRecord, - crdtValueToPlain, - getArrayProp, - getCellMap, getDatabaseViews, + getCellMap, getMapProp, getRowCells, getStringProp, @@ -26,11 +23,21 @@ import { getTableContent, getTextProp, isCRDTMap, - type CRDTTextLike, - type CRDTUnknownArray, type CRDTUnknownMap, - type TableCellMap, } from "../editor/crdtShapes"; +import { AppHandleImpl } from "./appHandleImpl"; +import { + arrayValues, + getChildrenArray, + getDeltaFragments, + getMapEntries, + getPropsMap, + resolveText, + toDatabaseViewState, + toInlineDeltas, + toTableColumnSchema, +} from "./handleValueHelpers"; +import { TableCellHandleImpl } from "./tableCellHandleImpl"; // ── Factory Functions ─────────────────────────────────────── @@ -49,75 +56,19 @@ export function createAppHandle( crdtDoc: CRDTDocument, registry: SchemaRegistry, ): AppHandle { - return new AppHandleImpl(appId, doc, crdtDoc, registry); + return new AppHandleImpl(appId, doc, crdtDoc, registry, createBlockHandle); } // ── BlockHandleImpl ───────────────────────────────────────── const EMPTY_TABLE_COLUMNS: readonly TableColumnSchema[] = []; const EMPTY_DATABASE_VIEWS: readonly DatabaseViewState[] = []; -type TextDelta = { - insert: unknown; - attributes?: Record; -}; - -function getMapEntries(map: CRDTUnknownMap | null): Iterable<[string, unknown]> { - return map?.entries?.() ?? []; -} - -function getChildrenArray(blockMap: CRDTUnknownMap): CRDTUnknownArray | null { - return getArrayProp(blockMap, "children"); -} - -function getPropsMap(blockMap: CRDTUnknownMap): CRDTUnknownMap | null { - return getMapProp(blockMap, "props"); -} - -function getDeltaFragments(text: CRDTTextLike | null): TextDelta[] { - return typeof text?.toDelta === "function" ? text.toDelta() : []; -} - -function toInlineDeltaInsert(value: unknown): string | InlineNodeDeltaInsert { - if (typeof value === "string") { - return value; - } - if (!value || typeof value !== "object") { - return ""; - } - const record = value as Record; - const type = typeof record.type === "string" ? record.type : ""; - if (!type) { - return ""; - } - const props: Record = {}; - for (const [key, entry] of Object.entries(record)) { - if (key === "type") { - continue; - } - props[key] = entry; - } - return { type, props }; -} - -function toInlineDeltas(content: CRDTTextLike | null): InlineDelta[] { - if (typeof content?.toDelta !== "function") { - return []; - } - return getDeltaFragments(content).map((delta) => ({ - insert: toInlineDeltaInsert(delta.insert), - ...(delta.attributes ? { attributes: delta.attributes } : {}), - })); -} - -function arrayValues(array: CRDTUnknownArray): T[] { - return array.toArray?.() ?? Array.from({ length: array.length }, (_, index) => array.get(index)); -} class TableRowHandleImpl implements TableRowHandle { constructor( readonly id: string, readonly index: number, - ) { } + ) {} } class BlockHandleImpl implements BlockHandle { @@ -126,7 +77,7 @@ class BlockHandleImpl implements BlockHandle { private readonly _doc: PenDocument, private readonly _crdtDoc: CRDTDocument, private readonly _registry: SchemaRegistry, - ) { } + ) {} get id(): string { return this._id; @@ -181,8 +132,9 @@ class BlockHandleImpl implements BlockHandle { } get parent(): BlockHandle | null { - const parentId = (this.props as Record) - .parentId as string | undefined; + const parentId = (this.props as Record).parentId as + | string + | undefined; if (parentId && this._doc.blocks.has(parentId)) { return new BlockHandleImpl( parentId, @@ -333,14 +285,21 @@ class BlockHandleImpl implements BlockHandle { const result: AppHandle[] = []; for (const [appId, rawAppMap] of this._doc.apps.entries()) { if (!isCRDTMap(rawAppMap)) continue; - const placement = rawAppMap.get("placement") as AppPlacement | undefined; - if (placement && "blockId" in placement && placement.blockId === this._id) { + const placement = rawAppMap.get("placement") as + | AppPlacement + | undefined; + if ( + placement && + "blockId" in placement && + placement.blockId === this._id + ) { result.push( new AppHandleImpl( appId, this._doc, this._crdtDoc, this._registry, + createBlockHandle, ), ); } @@ -356,7 +315,7 @@ class BlockHandleImpl implements BlockHandle { const text = content.toString(); if (text === "\u200B") return ""; if (options?.resolved) { - return this.resolveText(content); + return resolveText(content); } return text; } @@ -379,7 +338,15 @@ class BlockHandleImpl implements BlockHandle { } length(): number { - return this.textContent().length; + const content = getTextProp(this.blockMap, "content"); + if (!content) { + return 0; + } + const text = content.toString(); + if (!text || text === "\u200B") { + return 0; + } + return content.length; } // ── Metadata ────────────────────────────────────────── @@ -460,7 +427,7 @@ class BlockHandleImpl implements BlockHandle { const columns = getTableColumns(this.blockMap); if (!columns) return EMPTY_TABLE_COLUMNS; return arrayValues(columns) - .map((column) => this.toTableColumnSchema(column)) + .map((column) => toTableColumnSchema(column)) .filter((column): column is TableColumnSchema => column !== null); } @@ -469,7 +436,7 @@ class BlockHandleImpl implements BlockHandle { const views = getDatabaseViews(this.blockMap); if (!views) return EMPTY_DATABASE_VIEWS; return arrayValues(views) - .map((view) => this.toDatabaseViewState(view)) + .map((view) => toDatabaseViewState(view)) .filter((view): view is DatabaseViewState => view !== null); } @@ -485,7 +452,9 @@ class BlockHandleImpl implements BlockHandle { if (!primaryViewId) { return views[0] ?? null; } - return views.find((view) => view.id === primaryViewId) ?? views[0] ?? null; + return ( + views.find((view) => view.id === primaryViewId) ?? views[0] ?? null + ); } // ── Internal ────────────────────────────────────────── @@ -494,143 +463,6 @@ class BlockHandleImpl implements BlockHandle { return this.type === "table" || this.type === "database"; } - private resolveText(content: CRDTTextLike): string { - const deltas = getDeltaFragments(content); - let result = ""; - for (const d of deltas) { - if (typeof d.insert !== "string") continue; - const suggestion = d.attributes?.suggestion as - | { action?: string } - | undefined; - if (suggestion?.action === "delete") continue; - result += d.insert; - } - return result; - } - - private toTableColumnSchema(column: unknown): TableColumnSchema | null { - if (!column || typeof column !== "object") return null; - const mapLike = column as { - get?: (key: string) => unknown; - entries?: () => IterableIterator<[string, unknown]>; - }; - const id = mapLike.get?.("id"); - const title = mapLike.get?.("title"); - const type = mapLike.get?.("type"); - if (typeof id !== "string" || typeof title !== "string" || typeof type !== "string") { - return null; - } - const options = this.toPlainArray(mapLike.get?.("options")); - return { - id, - title, - type: type as TableColumnSchema["type"], - width: this.toNumber(mapLike.get?.("width")), - hidden: this.toBoolean(mapLike.get?.("hidden")), - pinned: this.toPinned(mapLike.get?.("pinned")), - options, - format: (this.toPlainObject(mapLike.get?.("format")) ?? undefined) as TableColumnSchema["format"], - readonly: this.toBoolean(mapLike.get?.("readonly")), - }; - } - - private toDatabaseViewState(view: unknown): DatabaseViewState | null { - if (!view || typeof view !== "object") return null; - const mapLike = view as { - get?: (key: string) => unknown; - }; - const id = mapLike.get?.("id"); - const type = mapLike.get?.("type"); - if (typeof id !== "string" || typeof type !== "string") { - return null; - } - - const filterValue = this.toPlainObject(mapLike.get?.("filter")); - - return { - id, - title: this.toString(mapLike.get?.("title")), - type: type as DatabaseViewState["type"], - visibleColumnIds: this.toStringArray(mapLike.get?.("visibleColumnIds")), - columnOrder: this.toStringArray(mapLike.get?.("columnOrder")), - sort: this.toPlainArray(mapLike.get?.("sort")) as DatabaseViewState["sort"], - filter: (filterValue as DatabaseViewState["filter"] | null) ?? null, - groupBy: this.toNullableString(mapLike.get?.("groupBy")), - rowPinning: this.toDatabaseRowPinning(mapLike.get?.("rowPinning")), - pageIndex: this.toNumber(mapLike.get?.("pageIndex")), - pageSize: this.toNumber(mapLike.get?.("pageSize")), - }; - } - - private toDatabaseRowPinning(value: unknown): DatabaseViewState["rowPinning"] { - if (!value || typeof value !== "object") { - return undefined; - } - const mapLike = value as { - get?: (key: string) => unknown; - }; - const topValues = this.toStringArray(mapLike.get?.("top")); - const bottomValues = this.toStringArray(mapLike.get?.("bottom")); - const top = topValues && topValues.length > 0 ? topValues : undefined; - const bottom = bottomValues && bottomValues.length > 0 ? bottomValues : undefined; - if (!top && !bottom) { - return undefined; - } - return { - top, - bottom, - }; - } - - private toPlainArray(value: unknown): TableColumnSchema["options"] { - if (!value || typeof (value as { toArray?: () => unknown[] }).toArray !== "function") { - return undefined; - } - const items = (value as { toArray: () => unknown[] }).toArray(); - return items - .map((item) => this.toPlainValue(item)) - .filter((item): item is Record => item !== null) - .map((item) => item as unknown as NonNullable[number]); - } - - private toPlainObject(value: unknown): Record | null { - return crdtMapToPlainRecord(value); - } - - private toPlainValue(value: unknown): unknown { - return crdtValueToPlain(value); - } - - private toNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; - } - - private toString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; - } - - private toNullableString(value: unknown): string | null | undefined { - if (value === null) return null; - return typeof value === "string" ? value : undefined; - } - - private toStringArray(value: unknown): string[] | undefined { - if (!value || typeof (value as { toArray?: () => unknown[] }).toArray !== "function") { - return undefined; - } - return (value as { toArray: () => unknown[] }) - .toArray() - .filter((entry): entry is string => typeof entry === "string"); - } - - private toBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; - } - - private toPinned(value: unknown): "left" | "right" | undefined { - return value === "left" || value === "right" ? value : undefined; - } - private get blockMap(): CRDTUnknownMap { const map = this._doc.blocks.get(this._id); if (!isCRDTMap(map)) throw new Error(`Block not found: ${this._id}`); @@ -638,109 +470,3 @@ class BlockHandleImpl implements BlockHandle { } } -// ── AppHandleImpl ─────────────────────────────────────────── - -class AppHandleImpl implements AppHandle { - constructor( - private readonly _id: string, - private readonly _doc: PenDocument, - private readonly _crdtDoc: CRDTDocument, - private readonly _registry: SchemaRegistry, - ) { } - - get id(): string { - return this._id; - } - - get type(): string { - return this.appMap.get("type") as string; - } - - get placement(): AppPlacement { - return this.appMap.get("placement") as AppPlacement; - } - - get config(): Readonly> { - return crdtMapToPlainRecord(getMapProp(this.appMap, "config")) ?? {}; - } - - get anchorBlock(): BlockHandle | null { - const placement = this.placement; - if (placement && "blockId" in placement && placement.blockId) { - return createBlockHandle( - placement.blockId as string, - this._doc, - this._crdtDoc, - this._registry, - ); - } - return null; - } - - private get appMap(): CRDTUnknownMap { - const map = this._doc.apps.get(this._id); - if (!isCRDTMap(map)) throw new Error(`App not found: ${this._id}`); - return map; - } -} - -// ── TableCellHandleImpl ──────────────────────────────────── - -class TableCellHandleImpl implements TableCellHandle { - constructor( - private readonly _cellMap: TableCellMap, - private readonly _row: number, - private readonly _col: number, - ) { } - - get id(): string { - return getStringProp(this._cellMap, "id") ?? ""; - } - - get row(): number { - return this._row; - } - - get col(): number { - return this._col; - } - - textContent(): string { - const content = getTextProp(this._cellMap, "content"); - if (content) { - const text = content.toString(); - if (text === "\u200B") return ""; - return text; - } - return ""; - } - - length(): number { - const content = getTextProp(this._cellMap, "content"); - if (typeof content?.toDelta === "function") { - return getDeltaFragments(content).reduce((total: number, delta) => { - if (typeof delta.insert === "string") { - return total + delta.insert.length; - } - return total + 1; - }, 0); - } - return this.textContent().length; - } - - inlineDeltas(): InlineDelta[] { - const content = getTextProp(this._cellMap, "content"); - return toInlineDeltas(content); - } - - textDeltas(): Array<{ - insert: string; - attributes?: Record; - }> { - return this.inlineDeltas().map((delta) => ({ - insert: typeof delta.insert === "string" ? delta.insert : "", - ...(delta.attributes ? { attributes: delta.attributes } : {}), - })); - } -} - diff --git a/packages/core/src/schema/tableCellHandleImpl.ts b/packages/core/src/schema/tableCellHandleImpl.ts new file mode 100644 index 0000000..0f72122 --- /dev/null +++ b/packages/core/src/schema/tableCellHandleImpl.ts @@ -0,0 +1,65 @@ +import type { InlineDelta, TableCellHandle } from "@pen/types"; +import { + getStringProp, + getTextProp, + type TableCellMap, +} from "../editor/crdtShapes"; +import { getDeltaFragments, toInlineDeltas } from "./handleValueHelpers"; + +export class TableCellHandleImpl implements TableCellHandle { + constructor( + private readonly _cellMap: TableCellMap, + private readonly _row: number, + private readonly _col: number, + ) {} + + get id(): string { + return getStringProp(this._cellMap, "id") ?? ""; + } + + get row(): number { + return this._row; + } + + get col(): number { + return this._col; + } + + textContent(): string { + const content = getTextProp(this._cellMap, "content"); + if (content) { + const text = content.toString(); + if (text === "\u200B") return ""; + return text; + } + return ""; + } + + length(): number { + const content = getTextProp(this._cellMap, "content"); + if (typeof content?.toDelta === "function") { + return getDeltaFragments(content).reduce((total: number, delta) => { + if (typeof delta.insert === "string") { + return total + delta.insert.length; + } + return total + 1; + }, 0); + } + return this.textContent().length; + } + + inlineDeltas(): InlineDelta[] { + const content = getTextProp(this._cellMap, "content"); + return toInlineDeltas(content); + } + + textDeltas(): Array<{ + insert: string; + attributes?: Record; + }> { + return this.inlineDeltas().map((delta) => ({ + insert: typeof delta.insert === "string" ? delta.insert : "", + ...(delta.attributes ? { attributes: delta.attributes } : {}), + })); + } +} diff --git a/packages/crdt/yjs/README.md b/packages/crdt/yjs/README.md index de43d3f..7b3509a 100644 --- a/packages/crdt/yjs/README.md +++ b/packages/crdt/yjs/README.md @@ -7,9 +7,74 @@ This package provides: - the Pen Yjs CRDT adapter via `yjsAdapter()` - Yjs awareness helpers - a thin provider wrapper for multiplayer sessions +- Yjs state-vector helpers for sync/workflow barriers +- generic Yjs text and array field adapters for host-owned CRDT fields +- generic extension-root helpers for app-owned Yjs maps under `apps` It does **not** implement WebSocket transport or a custom Yjs sync provider. +## State barriers + +```ts +import { + encodeYjsStateVectorBase64, + isYjsStateVectorBase64Satisfied, +} from "@pen/crdt-yjs"; + +const required = encodeYjsStateVectorBase64(ydoc); +const ready = isYjsStateVectorBase64Satisfied(currentStateVector, required); +``` + +Use state-vector helpers when a host workflow needs to wait until a synced document includes a known local edit. + +## Field adapters + +```ts +import { + createYArrayFieldAdapter, + createYTextFieldAdapter, +} from "@pen/crdt-yjs"; + +const title = createYTextFieldAdapter({ + doc: ydoc, + root: ydoc.getMap("app"), + key: "title", + normalize: (value) => value.trim(), +}); + +const tags = createYArrayFieldAdapter({ + doc: ydoc, + root: ydoc.getMap("app"), + key: "tags", + getId: (tag) => tag.id, +}); +``` + +Adapters are storage helpers only. Product validation, labels, contacts, auth, and delivery semantics belong in the host app. + +## Extension roots + +```ts +import { ensureExtensionRoot, readExtensionRoot } from "@pen/crdt-yjs"; + +const root = ensureExtensionRoot({ + doc: ydoc, + namespace: "com.example.workflow", + version: 1, + shape: { + title: "text", + requests: "array", + }, +}); + +const existing = readExtensionRoot({ + doc: ydoc, + namespace: "com.example.workflow", +}); +``` + +Extension roots give host apps a predictable place for CRDT-backed data that travels with the Pen document while staying outside Pen's core block model. + ## Collaboration boundary When using multiplayer with Yjs, Pen expects the application to choose the provider and hand Pen a `MultiplayerSession`. diff --git a/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts b/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts new file mode 100644 index 0000000..c74335c --- /dev/null +++ b/packages/crdt/yjs/src/__tests__/extensionRoots.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import { ensureExtensionRoot, readExtensionRoot } from "../extensionRoots"; + +describe("extensionRoots", () => { + it("initializes a namespaced root with version and shape", () => { + const doc = new Y.Doc(); + + const root = ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + shape: { + title: "text", + tags: "array", + settings: "map", + ready: "value", + }, + }); + + expect(root.map.get("version")).toBe(1); + expect(doc.getMap("extensionRoots").get("example.tags")).toBe(root.map); + expect(doc.getMap("apps").get("example.tags")).toBeUndefined(); + expect(root.map.get("title")).toBeInstanceOf(Y.Text); + expect(root.map.get("tags")).toBeInstanceOf(Y.Array); + expect(root.map.get("settings")).toBeInstanceOf(Y.Map); + expect(root.map.get("ready")).toBeNull(); + }); + + it("is idempotent and reads existing roots", () => { + const doc = new Y.Doc(); + const first = ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + }); + const second = ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + }); + + expect(second.map).toBe(first.map); + expect(readExtensionRoot({ doc, namespace: "example.tags" })).toEqual({ + namespace: "example.tags", + version: 1, + map: first.map, + }); + }); + + it("fails safely on version or shape mismatches", () => { + const doc = new Y.Doc(); + ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + shape: { tags: "array" }, + }); + + expect(() => + ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 2, + }), + ).toThrow("version mismatch"); + + expect(() => + ensureExtensionRoot({ + doc, + namespace: "example.tags", + version: 1, + shape: { tags: "map" }, + }), + ).toThrow('field "tags" exists but is not map'); + }); +}); diff --git a/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts b/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts new file mode 100644 index 0000000..4293e3f --- /dev/null +++ b/packages/crdt/yjs/src/__tests__/fieldAdapters.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; + +import { + createYArrayFieldAdapter, + createYTextFieldAdapter, +} from "../fieldAdapters"; + +type TestItem = { + id: string; + label: string; + value?: string; +}; + +describe("fieldAdapters", () => { + it("reads, writes, normalizes, and observes Y.Text fields", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const origins: unknown[] = []; + doc.on("afterTransaction", (transaction) => { + origins.push(transaction.origin); + }); + const onChange = vi.fn(); + const field = createYTextFieldAdapter({ + doc, + root, + key: "title", + normalize: (value) => value.trim(), + }); + + const unsubscribe = field.observe(onChange); + field.replace(" Hello "); + + expect(origins).toContain("pen:y-text-field:title:ensure"); + expect(field.read()).toBe("Hello"); + expect(onChange).toHaveBeenCalledTimes(1); + unsubscribe(); + field.replace("Bye"); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("replaces, inserts, updates, and removes array items by stable id", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const field = createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + normalizeItem: (item) => ({ + ...item, + label: item.label.trim(), + }), + }); + + field.replace([{ id: "a", label: " A " }]); + field.insert({ id: "b", label: "B" }); + expect(field.update("a", { value: "updated" })).toBe(true); + expect(field.remove("b")).toBe(true); + + expect(field.read()).toEqual([ + { id: "a", label: "A", value: "updated" }, + ]); + }); + + it("updates an array item without replacing sibling Y.Map instances", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const field = createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + }); + + field.replace([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + ]); + const array = root.get("items") as Y.Array>; + const siblingBefore = array.get(1); + + field.update("a", { label: "A+" }); + + expect(array.get(1)).toBe(siblingBefore); + expect(field.read()).toEqual([ + { id: "a", label: "A+" }, + { id: "b", label: "B" }, + ]); + }); + + it("observes array item updates", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + const onChange = vi.fn(); + const field = createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + }); + + field.replace([{ id: "a", label: "A" }]); + const unsubscribe = field.observe(onChange); + field.update("a", { label: "A+" }); + + expect(onChange).toHaveBeenCalledTimes(1); + unsubscribe(); + field.update("a", { label: "A++" }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("fails safely when existing fields have the wrong Yjs type", () => { + const doc = new Y.Doc(); + const root = doc.getMap("fields"); + root.set("title", new Y.Array()); + root.set("items", new Y.Text()); + + expect(() => + createYTextFieldAdapter({ + doc, + root, + key: "title", + }), + ).toThrow('Yjs field "title" exists but is not text'); + + expect(() => + createYArrayFieldAdapter({ + doc, + root, + key: "items", + getId: (item) => item.id, + }), + ).toThrow('Yjs field "items" exists but is not array'); + }); +}); diff --git a/packages/crdt/yjs/src/__tests__/stateVector.test.ts b/packages/crdt/yjs/src/__tests__/stateVector.test.ts new file mode 100644 index 0000000..2814f86 --- /dev/null +++ b/packages/crdt/yjs/src/__tests__/stateVector.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import { + compareYjsStateVectorBase64, + compareYjsStateVectors, + encodeYjsStateVector, + encodeYjsStateVectorBase64, + isYjsStateVectorBase64Satisfied, + isYjsStateVectorSatisfied, +} from "../stateVector"; + +describe("stateVector", () => { + it("treats an absent required vector as satisfied", () => { + const doc = new Y.Doc(); + + expect(isYjsStateVectorSatisfied(encodeYjsStateVector(doc))).toBe(true); + }); + + it("satisfies identical state vectors", () => { + const doc = new Y.Doc(); + doc.getText("body").insert(0, "Hello"); + const stateVector = encodeYjsStateVector(doc); + + expect(compareYjsStateVectors(stateVector, stateVector)).toEqual({ + satisfied: true, + missingClients: [], + }); + }); + + it("satisfies when the current vector has higher clocks", () => { + const doc = new Y.Doc(); + const text = doc.getText("body"); + text.insert(0, "A"); + const required = encodeYjsStateVector(doc); + + text.insert(1, "B"); + const current = encodeYjsStateVector(doc); + + expect(isYjsStateVectorSatisfied(current, required)).toBe(true); + }); + + it("reports missing client clocks", () => { + const doc = new Y.Doc(); + const text = doc.getText("body"); + text.insert(0, "A"); + const current = encodeYjsStateVector(doc); + + text.insert(1, "B"); + const required = encodeYjsStateVector(doc); + + const result = compareYjsStateVectors(current, required); + expect(result.satisfied).toBe(false); + expect(result.missingClients).toHaveLength(1); + expect(result.missingClients[0]?.currentClock).toBeLessThan( + result.missingClients[0]?.requiredClock ?? 0, + ); + }); + + it("ignores extra current clients", () => { + const requiredDoc = new Y.Doc(); + requiredDoc.getText("body").insert(0, "Required"); + const requiredUpdate = Y.encodeStateAsUpdate(requiredDoc); + const required = encodeYjsStateVector(requiredDoc); + + const currentDoc = new Y.Doc(); + Y.applyUpdate(currentDoc, requiredUpdate); + currentDoc.getText("local").insert(0, "Extra"); + + expect( + isYjsStateVectorSatisfied( + encodeYjsStateVector(currentDoc), + required, + ), + ).toBe(true); + }); + + it("round-trips base64 state vectors", () => { + const doc = new Y.Doc(); + doc.getText("body").insert(0, "Hello"); + const stateVector = encodeYjsStateVectorBase64(doc); + + expect(isYjsStateVectorBase64Satisfied(stateVector, stateVector)).toBe( + true, + ); + }); + + it("fails closed for malformed state vectors", () => { + const doc = new Y.Doc(); + doc.getText("body").insert(0, "Hello"); + + const result = compareYjsStateVectorBase64( + "not-a-state-vector", + encodeYjsStateVectorBase64(doc), + ); + + expect(result.satisfied).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/packages/crdt/yjs/src/extensionRoots.ts b/packages/crdt/yjs/src/extensionRoots.ts new file mode 100644 index 0000000..ab0584d --- /dev/null +++ b/packages/crdt/yjs/src/extensionRoots.ts @@ -0,0 +1,161 @@ +import * as Y from "yjs"; + +export type YjsExtensionRootFieldType = "array" | "map" | "text" | "value"; + +export type YjsExtensionRootShape = Record; + +export interface YjsExtensionRootOptions { + doc: Y.Doc; + namespace: string; + version: number; + shape?: YjsExtensionRootShape; + rootName?: string; + origin?: unknown; +} + +export interface YjsExtensionRoot { + namespace: string; + version: number; + map: Y.Map; +} + +export interface YjsExtensionRootReadOptions { + doc: Y.Doc; + namespace: string; + rootName?: string; +} + +export class YjsExtensionRootError extends Error { + constructor(message: string) { + super(message); + this.name = "YjsExtensionRootError"; + } +} + +const DEFAULT_ROOT_NAME = "extensionRoots"; +const VERSION_KEY = "version"; + +export function ensureExtensionRoot( + options: YjsExtensionRootOptions, +): YjsExtensionRoot { + const apps = options.doc.getMap>( + options.rootName ?? DEFAULT_ROOT_NAME, + ); + let root = apps.get(options.namespace); + + options.doc.transact( + () => { + if (root !== undefined && !(root instanceof Y.Map)) { + throw new YjsExtensionRootError( + `Extension root "${options.namespace}" exists but is not a Y.Map.`, + ); + } + + if (!root) { + root = new Y.Map(); + apps.set(options.namespace, root); + } + + const existingVersion = root.get(VERSION_KEY); + if ( + existingVersion !== undefined && + existingVersion !== options.version + ) { + throw new YjsExtensionRootError( + `Extension root "${options.namespace}" version mismatch: expected ${options.version}, got ${String(existingVersion)}.`, + ); + } + + root.set(VERSION_KEY, options.version); + ensureExtensionRootShape(root, options.shape); + }, + options.origin ?? `pen:extension-root:${options.namespace}`, + ); + + if (!root) { + throw new YjsExtensionRootError( + `Extension root "${options.namespace}" was not initialized.`, + ); + } + + return { + namespace: options.namespace, + version: options.version, + map: root, + }; +} + +export function readExtensionRoot( + options: YjsExtensionRootReadOptions, +): YjsExtensionRoot | undefined { + const apps = options.doc.getMap>( + options.rootName ?? DEFAULT_ROOT_NAME, + ); + const root = apps.get(options.namespace); + if (!(root instanceof Y.Map)) { + return undefined; + } + + const version = root.get(VERSION_KEY); + return { + namespace: options.namespace, + version: typeof version === "number" ? version : 0, + map: root, + }; +} + +function ensureExtensionRootShape( + root: Y.Map, + shape: YjsExtensionRootShape | undefined, +): void { + if (!shape) { + return; + } + + for (const [key, type] of Object.entries(shape)) { + if (key === VERSION_KEY) { + continue; + } + + const current = root.get(key); + if (current !== undefined) { + if (!isExpectedFieldType(current, type)) { + throw new YjsExtensionRootError( + `Extension root field "${key}" exists but is not ${type}.`, + ); + } + continue; + } + + root.set(key, createFieldValue(type)); + } +} + +function isExpectedFieldType( + value: unknown, + type: YjsExtensionRootFieldType, +): boolean { + if (type === "array") { + return value instanceof Y.Array; + } + if (type === "map") { + return value instanceof Y.Map; + } + if (type === "text") { + return value instanceof Y.Text; + } + return !(value instanceof Y.AbstractType); +} + +function createFieldValue(type: YjsExtensionRootFieldType): unknown { + if (type === "array") { + return new Y.Array(); + } + if (type === "map") { + return new Y.Map(); + } + if (type === "text") { + return new Y.Text(); + } + return null; +} diff --git a/packages/crdt/yjs/src/fieldAdapters.ts b/packages/crdt/yjs/src/fieldAdapters.ts new file mode 100644 index 0000000..2951ab9 --- /dev/null +++ b/packages/crdt/yjs/src/fieldAdapters.ts @@ -0,0 +1,249 @@ +import * as Y from "yjs"; + +export type YjsFieldObserver = () => void; +export type YjsFieldUnsubscribe = () => void; + +export interface YTextFieldAdapter { + read(): string; + replace(value: string): void; + observe(callback: YjsFieldObserver): YjsFieldUnsubscribe; +} + +export interface CreateYTextFieldAdapterOptions { + doc: Y.Doc; + root: Y.Map; + key: string; + normalize?: (value: string) => string; + origin?: unknown; +} + +export interface YArrayFieldAdapter { + read(): T[]; + replace(value: readonly T[]): void; + insert(item: T, index?: number): void; + update(id: string, patch: Partial | ((item: T) => T)): boolean; + remove(id: string): boolean; + observe(callback: YjsFieldObserver): YjsFieldUnsubscribe; +} + +export interface CreateYArrayFieldAdapterOptions { + doc: Y.Doc; + root: Y.Map; + key: string; + getId: (item: T) => string; + normalizeItem?: (item: T) => T; + fromYMap?: (item: Y.Map) => T | undefined; + toYMap?: (item: T) => Y.Map; + origin?: unknown; +} + +export class YjsFieldAdapterError extends Error { + constructor(message: string) { + super(message); + this.name = "YjsFieldAdapterError"; + } +} + +export function createYTextFieldAdapter( + options: CreateYTextFieldAdapterOptions, +): YTextFieldAdapter { + const text = ensureYText(options); + + return { + read() { + return text.toString(); + }, + replace(value) { + const normalized = options.normalize?.(value) ?? value; + options.doc.transact( + () => { + text.delete(0, text.length); + text.insert(0, normalized); + }, + options.origin ?? `pen:y-text-field:${options.key}`, + ); + }, + observe(callback) { + text.observe(callback); + return () => text.unobserve(callback); + }, + }; +} + +export function createYArrayFieldAdapter( + options: CreateYArrayFieldAdapterOptions, +): YArrayFieldAdapter { + const array = ensureYArray(options); + const readItem = options.fromYMap ?? defaultFromYMap; + const serializeItem = options.toYMap ?? defaultToYMap; + const writeItem = (item: T) => + serializeItem(normalizeArrayItem(item, options)); + + return { + read() { + return array.toArray().flatMap((item) => { + const value = readItem(item); + return value ? [normalizeArrayItem(value, options)] : []; + }); + }, + replace(value) { + options.doc.transact( + () => { + array.delete(0, array.length); + array.push(value.map((item) => writeItem(item))); + }, + options.origin ?? `pen:y-array-field:${options.key}:replace`, + ); + }, + insert(item, index = array.length) { + options.doc.transact( + () => { + array.insert(Math.min(Math.max(index, 0), array.length), [ + writeItem(item), + ]); + }, + options.origin ?? `pen:y-array-field:${options.key}:insert`, + ); + }, + update(id, patch) { + const match = findArrayItem(array, readItem, options.getId, id); + if (!match) { + return false; + } + + const current = normalizeArrayItem(match.item, options); + const next = + typeof patch === "function" + ? patch(current) + : ({ ...current, ...patch } as T); + const normalized = normalizeArrayItem(next, options); + options.doc.transact( + () => { + if (options.toYMap) { + array.delete(match.index, 1); + array.insert(match.index, [writeItem(normalized)]); + return; + } + replaceYMapContents(match.yMap, normalized); + }, + options.origin ?? `pen:y-array-field:${options.key}:update`, + ); + return true; + }, + remove(id) { + const match = findArrayItem(array, readItem, options.getId, id); + if (!match) { + return false; + } + + options.doc.transact( + () => { + array.delete(match.index, 1); + }, + options.origin ?? `pen:y-array-field:${options.key}:remove`, + ); + return true; + }, + observe(callback) { + array.observeDeep(callback); + return () => array.unobserveDeep(callback); + }, + }; +} + +function ensureYText(options: CreateYTextFieldAdapterOptions): Y.Text { + const current = options.root.get(options.key); + if (current !== undefined) { + if (current instanceof Y.Text) { + return current; + } + throw new YjsFieldAdapterError( + `Yjs field "${options.key}" exists but is not text.`, + ); + } + + const next = new Y.Text(); + options.doc.transact( + () => { + options.root.set(options.key, next); + }, + options.origin ?? `pen:y-text-field:${options.key}:ensure`, + ); + return next; +} + +function ensureYArray(options: { + doc: Y.Doc; + root: Y.Map; + key: string; + origin?: unknown; +}): Y.Array> { + const current = options.root.get(options.key); + if (current !== undefined) { + if (current instanceof Y.Array) { + return current as Y.Array>; + } + throw new YjsFieldAdapterError( + `Yjs field "${options.key}" exists but is not array.`, + ); + } + + const next = new Y.Array>(); + options.doc.transact( + () => { + options.root.set(options.key, next); + }, + options.origin ?? `pen:y-array-field:${options.key}:ensure`, + ); + return next; +} + +function normalizeArrayItem( + item: T, + options: CreateYArrayFieldAdapterOptions, +): T { + return options.normalizeItem?.(item) ?? item; +} + +function defaultToYMap(item: T): Y.Map { + const yMap = new Y.Map(); + for (const [key, value] of Object.entries(item)) { + yMap.set(key, value); + } + return yMap; +} + +function defaultFromYMap( + item: Y.Map, +): T | undefined { + return Object.fromEntries(item.entries()) as T; +} + +function findArrayItem( + array: Y.Array>, + readItem: (item: Y.Map) => T | undefined, + getId: (item: T) => string, + id: string, +): { index: number; yMap: Y.Map; item: T } | undefined { + const values = array.toArray(); + for (let index = 0; index < values.length; index += 1) { + const yMap = values[index]!; + const item = readItem(yMap); + if (item && getId(item) === id) { + return { index, yMap, item }; + } + } + return undefined; +} + +function replaceYMapContents( + target: Y.Map, + source: T, +): void { + for (const key of Array.from(target.keys())) { + target.delete(key); + } + for (const [key, value] of Object.entries(source)) { + target.set(key, value); + } +} diff --git a/packages/crdt/yjs/src/index.ts b/packages/crdt/yjs/src/index.ts index 078c32f..1904e75 100644 --- a/packages/crdt/yjs/src/index.ts +++ b/packages/crdt/yjs/src/index.ts @@ -37,3 +37,40 @@ export type { DocumentValidationResult, DocumentValidationError, } from "./document"; +export { + compareYjsStateVectorBase64, + compareYjsStateVectors, + decodeYjsStateVectorBase64, + encodeYjsStateVector, + encodeYjsStateVectorBase64, + isYjsStateVectorBase64Satisfied, + isYjsStateVectorSatisfied, +} from "./stateVector"; +export type { + YjsStateVectorComparison, + YjsStateVectorMissingClient, +} from "./stateVector"; +export { + createYArrayFieldAdapter, + createYTextFieldAdapter, +} from "./fieldAdapters"; +export type { + CreateYArrayFieldAdapterOptions, + CreateYTextFieldAdapterOptions, + YArrayFieldAdapter, + YTextFieldAdapter, + YjsFieldObserver, + YjsFieldUnsubscribe, +} from "./fieldAdapters"; +export { + YjsExtensionRootError, + ensureExtensionRoot, + readExtensionRoot, +} from "./extensionRoots"; +export type { + YjsExtensionRoot, + YjsExtensionRootFieldType, + YjsExtensionRootOptions, + YjsExtensionRootReadOptions, + YjsExtensionRootShape, +} from "./extensionRoots"; diff --git a/packages/crdt/yjs/src/stateVector.ts b/packages/crdt/yjs/src/stateVector.ts new file mode 100644 index 0000000..9e7c1f3 --- /dev/null +++ b/packages/crdt/yjs/src/stateVector.ts @@ -0,0 +1,155 @@ +import * as Y from "yjs"; + +type Base64Buffer = { + toString(encoding: "base64"): string; +}; + +type BufferConstructorLike = { + from(value: Uint8Array): Base64Buffer; + from(value: string, encoding: "base64"): Uint8Array; +}; + +type Base64Globals = typeof globalThis & { + Buffer?: BufferConstructorLike; + atob?: (value: string) => string; + btoa?: (value: string) => string; +}; + +export interface YjsStateVectorMissingClient { + clientId: number; + currentClock: number; + requiredClock: number; +} + +export interface YjsStateVectorComparison { + satisfied: boolean; + missingClients: YjsStateVectorMissingClient[]; + error?: string; +} + +export function encodeYjsStateVector(doc: Y.Doc): Uint8Array { + return Y.encodeStateVector(doc); +} + +export function encodeYjsStateVectorBase64(doc: Y.Doc): string { + return encodeUint8ArrayToBase64(encodeYjsStateVector(doc)); +} + +export function decodeYjsStateVectorBase64(value: string): Uint8Array { + return decodeBase64ToUint8Array(value); +} + +export function isYjsStateVectorSatisfied( + currentStateVector: Uint8Array, + requiredStateVector?: Uint8Array, +): boolean { + return compareYjsStateVectors(currentStateVector, requiredStateVector) + .satisfied; +} + +export function isYjsStateVectorBase64Satisfied( + currentStateVector: string, + requiredStateVector?: string, +): boolean { + return compareYjsStateVectorBase64(currentStateVector, requiredStateVector) + .satisfied; +} + +export function compareYjsStateVectorBase64( + currentStateVector: string, + requiredStateVector?: string, +): YjsStateVectorComparison { + try { + return compareYjsStateVectors( + decodeYjsStateVectorBase64(currentStateVector), + requiredStateVector + ? decodeYjsStateVectorBase64(requiredStateVector) + : undefined, + ); + } catch (error) { + return invalidStateVectorComparison(error); + } +} + +export function compareYjsStateVectors( + currentStateVector: Uint8Array, + requiredStateVector?: Uint8Array, +): YjsStateVectorComparison { + if (!requiredStateVector) { + return { satisfied: true, missingClients: [] }; + } + + try { + const current = Y.decodeStateVector(currentStateVector); + const required = Y.decodeStateVector(requiredStateVector); + const missingClients: YjsStateVectorMissingClient[] = []; + + for (const [clientId, requiredClock] of required.entries()) { + const currentClock = current.get(clientId) ?? 0; + if (currentClock < requiredClock) { + missingClients.push({ clientId, currentClock, requiredClock }); + } + } + + return { + satisfied: missingClients.length === 0, + missingClients, + }; + } catch (error) { + return invalidStateVectorComparison(error); + } +} + +function invalidStateVectorComparison( + error: unknown, +): YjsStateVectorComparison { + return { + satisfied: false, + missingClients: [], + error: + error instanceof Error ? error.message : "Invalid Yjs state vector", + }; +} + +function encodeUint8ArrayToBase64(value: Uint8Array): string { + const buffer = getBuffer(); + if (buffer) { + return buffer.from(value).toString("base64"); + } + + const btoa = getBase64Global("btoa"); + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function decodeBase64ToUint8Array(value: string): Uint8Array { + const buffer = getBuffer(); + if (buffer) { + return new Uint8Array(buffer.from(value, "base64")); + } + + const atob = getBase64Global("atob"); + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function getBuffer(): BufferConstructorLike | undefined { + return (globalThis as Base64Globals).Buffer; +} + +function getBase64Global(name: "atob" | "btoa"): (value: string) => string { + const fn = (globalThis as Base64Globals)[name]; + if (!fn) { + throw new Error( + `globalThis.${name} is required to encode Yjs state vectors as base64`, + ); + } + return fn; +} diff --git a/packages/crdt/yjs/src/undo.ts b/packages/crdt/yjs/src/undo.ts index a77fb08..cdc2406 100644 --- a/packages/crdt/yjs/src/undo.ts +++ b/packages/crdt/yjs/src/undo.ts @@ -1,7 +1,7 @@ import type { - CRDTUndoManager, - CRDTUndoStackItem, - UndoManagerOptions, + CRDTUndoManager, + CRDTUndoStackItem, + UndoManagerOptions, } from "@pen/types"; import { HISTORY_ORIGIN_TAG } from "@pen/types"; import * as Y from "yjs"; @@ -9,100 +9,103 @@ import * as Y from "yjs"; import type { YjsCRDTDocument } from "./document"; export function createYjsUndoManager( - doc: YjsCRDTDocument, - options?: UndoManagerOptions, + doc: YjsCRDTDocument, + options?: UndoManagerOptions, ): CRDTUndoManager { - const { blockOrder, blocks } = doc.penDocument; - const trackedOrigins = new Set( - options?.trackedOrigins ?? ["user", "ai"], - ); + const { blockOrder, blocks } = doc.penDocument; + const trackedOrigins = new Set( + options?.trackedOriginTypes ?? ["user", "ai"], + ); - const undoManager = new Y.UndoManager([blockOrder, blocks], { - trackedOrigins, - captureTimeout: options?.captureTimeout ?? 0, - doc: doc.ydoc, - }); + const undoManager = new Y.UndoManager([blockOrder, blocks], { + trackedOrigins, + captureTimeout: options?.captureTimeout ?? 0, + doc: doc.ydoc, + }); - (undoManager as unknown as Record)[HISTORY_ORIGIN_TAG] = true; + (undoManager as unknown as Record)[HISTORY_ORIGIN_TAG] = + true; - const wrapStackItem = (stackItem: { - meta: Map; - }): CRDTUndoStackItem => ({ - getMeta(key: string): T | undefined { - return stackItem.meta.get(key) as T | undefined; - }, - setMeta(key: string, value: unknown): void { - stackItem.meta.set(key, value); - }, - }); + const wrapStackItem = (stackItem: { + meta: Map; + }): CRDTUndoStackItem => ({ + getMeta(key: string): T | undefined { + return stackItem.meta.get(key) as T | undefined; + }, + setMeta(key: string, value: unknown): void { + stackItem.meta.set(key, value); + }, + }); - return { - addTrackedOrigin(origin) { - undoManager.addTrackedOrigin(origin); - }, - removeTrackedOrigin(origin) { - undoManager.removeTrackedOrigin(origin); - }, - undo() { - if (undoManager.undoStack.length === 0) return false; - undoManager.undo(); - return true; - }, - redo() { - if (undoManager.redoStack.length === 0) return false; - undoManager.redo(); - return true; - }, - canUndo() { - return undoManager.undoStack.length > 0; - }, - canRedo() { - return undoManager.redoStack.length > 0; - }, - stopCapturing() { - undoManager.stopCapturing(); - }, - setCaptureTimeout(ms) { - (undoManager as Y.UndoManager & { captureTimeout?: number }).captureTimeout = ms; - }, - onStackItemAdded(callback) { - const handler = (event: { - stackItem: { meta: Map }; - type: "undo" | "redo"; - }) => { - callback(wrapStackItem(event.stackItem), event.type); - }; + return { + addTrackedOrigin(origin) { + undoManager.addTrackedOrigin(origin); + }, + removeTrackedOrigin(origin) { + undoManager.removeTrackedOrigin(origin); + }, + undo() { + if (undoManager.undoStack.length === 0) return false; + undoManager.undo(); + return true; + }, + redo() { + if (undoManager.redoStack.length === 0) return false; + undoManager.redo(); + return true; + }, + canUndo() { + return undoManager.undoStack.length > 0; + }, + canRedo() { + return undoManager.redoStack.length > 0; + }, + stopCapturing() { + undoManager.stopCapturing(); + }, + setCaptureTimeout(ms) { + ( + undoManager as Y.UndoManager & { captureTimeout?: number } + ).captureTimeout = ms; + }, + onStackItemAdded(callback) { + const handler = (event: { + stackItem: { meta: Map }; + type: "undo" | "redo"; + }) => { + callback(wrapStackItem(event.stackItem), event.type); + }; - undoManager.on("stack-item-added", handler); - return () => { - undoManager.off("stack-item-added", handler); - }; - }, - onStackItemUpdated(callback) { - const handler = (event: { - stackItem: { meta: Map }; - type: "undo" | "redo"; - }) => { - callback(wrapStackItem(event.stackItem), event.type); - }; + undoManager.on("stack-item-added", handler); + return () => { + undoManager.off("stack-item-added", handler); + }; + }, + onStackItemUpdated(callback) { + const handler = (event: { + stackItem: { meta: Map }; + type: "undo" | "redo"; + }) => { + callback(wrapStackItem(event.stackItem), event.type); + }; - undoManager.on("stack-item-updated", handler); - return () => { - undoManager.off("stack-item-updated", handler); - }; - }, - onStackItemPopped(callback) { - const handler = (event: { - stackItem: { meta: Map }; - type: "undo" | "redo"; - }) => { - callback(wrapStackItem(event.stackItem), event.type); - }; + undoManager.on("stack-item-updated", handler); + return () => { + undoManager.off("stack-item-updated", handler); + }; + }, + onStackItemPopped(callback) { + const handler = (event: { + stackItem: { meta: Map }; + type: "undo" | "redo"; + }) => { + callback(wrapStackItem(event.stackItem), event.type); + }; - undoManager.on("stack-item-popped", handler); - return () => { - undoManager.off("stack-item-popped", handler); - }; - }, - }; + undoManager.on("stack-item-popped", handler); + return () => { + undoManager.off("stack-item-popped", handler); + }; + }, + }; } diff --git a/packages/extensions/ai-autocomplete/package.json b/packages/extensions/ai-autocomplete/package.json index 56c118e..d8a8bac 100644 --- a/packages/extensions/ai-autocomplete/package.json +++ b/packages/extensions/ai-autocomplete/package.json @@ -36,7 +36,7 @@ "README.md", "LICENSE.md" ], - "sideEffects": false, + "sideEffects": true, "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", diff --git a/packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts new file mode 100644 index 0000000..1a7424c --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/continuationState.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { SelectionState } from "@pen/types"; +import { AutocompleteContinuationState } from "../continuationState"; +import type { AutocompleteStructuredCandidate } from "../structuredCandidate"; + +const candidate: AutocompleteStructuredCandidate = { + rawText: " world", + inlineText: " world", + appendedBlocks: [], + previewBlocks: [], +}; + +function textSelection(blockId: string, offset: number): SelectionState { + return { + type: "text", + anchor: { blockId, offset }, + focus: { blockId, offset }, + isCollapsed: true, + isMultiBlock: false, + blockRange: [blockId], + toRange: () => { + throw new Error("not needed for continuation state tests"); + }, + }; +} + +describe("AutocompleteContinuationState", () => { + it("activates a prefetched continuation only for the accepted caret", () => { + const state = new AutocompleteContinuationState(); + state.setPendingAcceptedContinuation({ + sourceRequestId: "request-1", + blockId: "block-1", + startOffset: 6, + continuationDepth: 1, + }); + state.setPrefetchedContinuation({ + sourceRequestId: "request-1", + requestId: "request-2", + blockId: "block-1", + startOffset: 6, + candidate, + continuationDepth: 1, + }); + + expect( + state.activatePendingAcceptedContinuation( + textSelection("block-1", 5), + ), + ).toBeNull(); + + const activated = state.activatePendingAcceptedContinuation( + textSelection("block-1", 6), + ); + + expect(activated).toMatchObject({ + requestId: "request-2", + blockId: "block-1", + startOffset: 6, + continuationDepth: 1, + }); + expect(state.sequence).toBe(activated); + }); + + it("consumes only the AI commit caused by accepting a sequence segment", () => { + const state = new AutocompleteContinuationState(); + + expect(state.consumeAcceptedAiCommit("ai")).toBe(false); + + state.beginAcceptingSequenceSegment(); + expect(state.consumeAcceptedAiCommit("user")).toBe(false); + expect(state.consumeAcceptedAiCommit("ai")).toBe(true); + expect(state.consumeAcceptedAiCommit("ai")).toBe(false); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts new file mode 100644 index 0000000..8be6808 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part2.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("accepts the whole visible suggestion and places the caret at the end", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot( + FIELD_EDITOR_SLOT_KEY, + fieldEditor, + ); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller).toBeTruthy(); + expect(inlineCompletion).toBeTruthy(); + + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " world from pen", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); + expect(editor.selection).toMatchObject({ + type: "text", + isCollapsed: true, + focus: { + blockId, + offset: 20, + }, + }); + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + editor.destroy(); + }); + + it("anchors end-of-line suggestions to the previous character for rendering", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world!" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world!", + ); + + expect(inlineCompletion?.buildDecorations()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "inline", + blockId, + from: 4, + to: 5, + attributes: expect.objectContaining({ + "data-suggestion-placement": "after", + }), + }), + ]), + ); + + editor.destroy(); + }); + + it("adds a separating space to prose suggestions when the model omits it", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "today, with more detail" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); + editor.selectText(blockId, 11, 11); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " today, with more detail", + ); + + editor.destroy(); + }); + + it("does not split a short partial word when normalizing prose suggestions", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "nd timeline" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "a" }]); + editor.selectText(blockId, 1, 1); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === "nd timeline", + ); + + editor.destroy(); + }); + + it("rejects tiny single-word prose suggestions", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "go" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); + editor.selectText(blockId, 11, 11); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition(() => controller?.getState().status === "idle"); + + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + expect(controller?.getState().visibleSuggestionId).toBeNull(); + + editor.destroy(); + }); + + it("accepts the full remaining completion in one step when full acceptance is enabled", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + acceptanceStrategy: "full", + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + expect(controller?.getState().settings.acceptanceStrategy).toBe("full"); + expect(controller?.acceptVisibleSuggestion()).toBe(true); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + expect(controller?.getState().metrics.acceptCount).toBe(1); + + editor.destroy(); + }); + + it("keeps scheduled requests alive across selection sync events", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 10, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request()).toBe(true); + expect(controller?.getState().status).toBe("scheduled"); + + editor.selectText(blockId, 5, 5); + + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + expect(controller?.getState().metrics.successCount).toBe(1); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts new file mode 100644 index 0000000..d880651 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part3.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("dismisses visible suggestions when the selection changes after showing", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + editor.selectText(blockId, 0, 0); + + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "selection-change", + ); + + editor.destroy(); + }); + + it("keeps visible suggestions when selection-change keeps the same caret", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + editor.selectText(blockId, 5, 5); + + expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( + " world from pen", + ); + expect(controller?.getState().visibleSuggestionId).not.toBeNull(); + expect(controller?.getState().status).toBe("showing"); + + editor.destroy(); + }); + + it("drops stale results and records the stale dismissal reason", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + staleAfterMs: 1, + model: { + async *stream() { + await new Promise((resolve) => setTimeout(resolve, 5)); + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => controller?.getState().metrics.staleDropCount === 1, + ); + + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().diagnostics.lastDismissReason).toBe("stale"); + + editor.destroy(); + }); + + it("blocks requests in code blocks when the block policy disables them", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowInCodeBlocks: false, + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " never runs" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(false); + expect(modelCalled).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + + editor.destroy(); + }); + + it("respects allowed block type policies before scheduling a request", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowedBlockTypes: ["heading"], + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " blocked" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(false); + expect(modelCalled).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "block-type-not-allowed", + ); + + editor.destroy(); + }); + + it("updates block policy at runtime without recreating the controller", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowInCodeBlocks: false, + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(false); + expect(controller?.request({ explicit: true })).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + + controller?.updateBlockPolicy({ allowInCodeBlocks: true }); + expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(true); + expect(controller?.getBlockPolicy().allowInCodeBlocks).toBe(true); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition(() => modelCalled); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts new file mode 100644 index 0000000..9e47707 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part4.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("cancels a scheduled request when runtime policy becomes ineligible", async () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 50, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request()).toBe(true); + expect(controller?.getState().status).toBe("scheduled"); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + await new Promise((resolve) => setTimeout(resolve, 70)); + + expect(modelCalled).toBe(false); + expect(controller?.getState().status).toBe("idle"); + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( + "scheduled", + ); + expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(1); + expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); + + controller?.updateBlockPolicy({ allowInCodeBlocks: true }); + expect(controller?.request({ explicit: true })).toBe(true); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBeNull(); + + editor.destroy(); + }); + + it("cancels an in-flight request when runtime policy becomes ineligible", async () => { + let activeEditor: ReturnType | null = null; + let streamStarted = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + streamStarted = true; + await new Promise((resolve) => setTimeout(resolve, 20)); + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition(() => streamStarted); + expect(controller?.getState().status).toBe("requesting"); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(controller?.getState().status).toBe("idle"); + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().metrics.successCount).toBe(0); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "code-block-disabled", + ); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( + "requesting", + ); + expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(1); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); + + editor.destroy(); + }); + + it("dismisses a visible suggestion when runtime policy becomes ineligible", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: null, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " value" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const codeBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "insert-block", + blockId: codeBlockId, + blockType: "codeBlock", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: codeBlockId, + offset: 0, + text: "const answer =", + }, + ]); + fieldEditor.focusBlockId = codeBlockId; + editor.selectText(codeBlockId, 14, 14); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => controller?.getState().visibleSuggestionId !== null, + ); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + + expect(controller?.getState().visibleSuggestionId).toBeNull(); + expect(controller?.getState().status).toBe("idle"); + expect(controller?.hasVisibleSuggestion()).toBe(false); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( + "showing", + ); + expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(1); + + const controllerImpl = controller as unknown as { + _state: { + blockPolicy: { + allowInCodeBlocks?: boolean; + allowInTables?: boolean; + allowedBlockTypes?: readonly string[]; + deniedBlockTypes?: readonly string[]; + }; + }; + _continuation: { + setSequence(sequence: { + requestId: string; + blockId: string; + startOffset: number; + candidate: { + rawText: string; + inlineText: string; + appendedBlocks: readonly []; + previewBlocks: readonly []; + }; + continuationDepth: number; + }): void; + }; + _setState: (nextState: { + status: "showing"; + activeRequestId: string; + visibleSuggestionId: string; + }) => void; + }; + controllerImpl._state.blockPolicy = { + ...controller!.getBlockPolicy(), + allowInCodeBlocks: false, + }; + controllerImpl._continuation.setSequence({ + requestId: "manual-policy-recheck", + blockId: codeBlockId, + startOffset: 14, + candidate: { + rawText: " value", + inlineText: " value", + appendedBlocks: [], + previewBlocks: [], + }, + continuationDepth: 0, + }); + controllerImpl._setState({ + status: "showing", + activeRequestId: "manual-policy-recheck", + visibleSuggestionId: "manual-policy-recheck", + }); + expect(controller?.acceptVisibleSuggestion()).toBe(false); + expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(2); + expect(controller?.getState().diagnostics.lastDismissReason).toBe( + "policy-change", + ); + + editor.destroy(); + }); + + it("blocks table-cell autocomplete when tables are disabled", () => { + let activeEditor: ReturnType | null = null; + let modelCalled = false; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + activeCellCoord: { blockId: "table-1", row: 0, col: 0 }, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + blockPolicy: { + allowInTables: false, + }, + model: { + async *stream() { + modelCalled = true; + yield { type: "text-delta" as const, delta: " cell" }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + + editor.apply([ + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: "last", + }, + ]); + fieldEditor.focusBlockId = "table-1"; + editor.selectText("table-1", 0, 0); + + const controller = getAutocompleteController(editor); + expect(controller?.request({ explicit: true })).toBe(false); + expect(modelCalled).toBe(false); + expect(controller?.getState().diagnostics.lastBlockedReason).toBe( + "table-disabled", + ); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts new file mode 100644 index 0000000..ef0702a --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part5.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("returns defensive block policy snapshots from both getters", () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + blockPolicy: { + allowedBlockTypes: ["paragraph"], + deniedBlockTypes: ["database"], + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + + const controller = getAutocompleteController(editor); + const snapshot = controller?.getBlockPolicy(); + const stateSnapshot = controller?.getState(); + expect(snapshot).toEqual({ + allowInCodeBlocks: true, + allowInTables: false, + allowedBlockTypes: ["paragraph"], + deniedBlockTypes: ["database"], + }); + + expect(() => { + if (snapshot?.allowedBlockTypes) { + (snapshot.allowedBlockTypes as string[]).push("heading"); + } + }).toThrow(); + expect(() => { + if (stateSnapshot?.blockPolicy.allowedBlockTypes) { + (stateSnapshot.blockPolicy.allowedBlockTypes as string[]).push("callout"); + } + }).toThrow(); + + expect(controller?.getBlockPolicy().allowedBlockTypes).toEqual(["paragraph"]); + expect(controller?.getState().blockPolicy.allowedBlockTypes).toEqual([ + "paragraph", + ]); + expect(stateSnapshot?.diagnostics.lastPolicyInvalidationStage).toBeNull(); + expect(stateSnapshot?.metrics.policyInvalidationScheduledCount).toBe(0); + + editor.destroy(); + }); + + it("returns stable cached snapshots until controller state changes", () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + blockPolicy: { + allowedBlockTypes: ["paragraph"], + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + + const controller = getAutocompleteController(editor); + const firstSnapshot = controller?.getSnapshot(); + const secondSnapshot = controller?.getSnapshot(); + const firstState = controller?.getState(); + const secondState = controller?.getState(); + const firstPolicy = controller?.getBlockPolicy(); + const secondPolicy = controller?.getBlockPolicy(); + const firstProviders = controller?.listProviderDescriptors(); + const secondProviders = controller?.listProviderDescriptors(); + + expect(firstSnapshot).toBe(secondSnapshot); + expect(firstState).toBe(secondState); + expect(firstPolicy).toBe(secondPolicy); + expect(firstProviders).toBe(secondProviders); + expect(firstSnapshot?.state).toBe(firstState); + expect(firstSnapshot?.state.blockPolicy).toBe(firstPolicy); + expect(firstSnapshot?.providerDescriptors).toBe(firstProviders); + + controller?.updateBlockPolicy({ allowInCodeBlocks: false }); + + const thirdSnapshot = controller?.getSnapshot(); + const thirdState = controller?.getState(); + const thirdPolicy = controller?.getBlockPolicy(); + + expect(thirdSnapshot).not.toBe(firstSnapshot); + expect(thirdState).not.toBe(firstState); + expect(thirdPolicy).not.toBe(firstPolicy); + expect(thirdSnapshot?.state).toBe(thirdState); + expect(thirdSnapshot?.state.blockPolicy).toBe(thirdPolicy); + expect(thirdSnapshot?.providerDescriptors).toBe(firstProviders); + expect(thirdState?.blockPolicy.allowInCodeBlocks).toBe(false); + expect(thirdPolicy?.allowInCodeBlocks).toBe(false); + + editor.destroy(); + }); + + it("prefetches a continuation after accepting the current suggestion", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + const requestModes: Array = []; + let secondPrompt = ""; + let thirdPrompt = ""; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream(options) { + callCount += 1; + requestModes.push(options.requestMode); + if (callCount === 1) { + yield { type: "text-delta" as const, delta: " world from pen" }; + yield { type: "done" as const }; + return; + } + if (callCount === 2) { + secondPrompt = String(options.messages[1]?.content ?? ""); + yield { + type: "text-delta" as const, + delta: + ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + }; + yield { type: "done" as const }; + return; + } + if (callCount === 3) { + thirdPrompt = String(options.messages[1]?.content ?? ""); + yield { + type: "text-delta" as const, + delta: + " The photos alone could fill a journal.\n\nYou should turn the trip into a full essay while the details are still vivid.\n\nStart with the beach at sunset and the best meal of the week.", + }; + yield { type: "done" as const }; + return; + } + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); + editor.selectText(blockId, 5, 5); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + ); + expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); + expect(secondPrompt).toContain('prefix="Hello world from pen"'); + expect(secondPrompt).toContain("target_scope=finish-paragraph"); + expect(requestModes).toEqual([ + "inline-autocomplete", + "inline-autocomplete", + ]); + expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( + ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " The photos alone could fill a journal.", + ); + expect(editor.getBlock(blockId)?.textContent()).toBe( + "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", + ); + expect(thirdPrompt).toContain( + 'prefix="Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell."', + ); + expect(thirdPrompt).toContain("target_scope=continue-across-paragraphs"); + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "You should turn the trip into a full essay while the details are still vivid.", + blockType: "paragraph", + }), + expect.objectContaining({ + text: "Start with the beach at sunset and the best meal of the week.", + blockType: "paragraph", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(secondBlock).toBeTruthy(); + expect(thirdBlock).toBeTruthy(); + expect(editor.getBlock(blockId)?.textContent()).toBe( + "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell. The photos alone could fill a journal.", + ); + expect(secondBlock?.textContent()).toBe( + "You should turn the trip into a full essay while the details are still vivid.", + ); + expect(thirdBlock?.textContent()).toBe( + "Start with the beach at sunset and the best meal of the week.", + ); + expect(editor.selection).toMatchObject({ + type: "text", + isCollapsed: true, + focus: { + blockId: thirdBlock?.id, + offset: 61, + }, + }); + expect(requestModes).toEqual([ + "inline-autocomplete", + "inline-autocomplete", + "inline-autocomplete", + ]); + expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); + + editor.destroy(); + }); + + it("accepts markdown continuation tails as structured blocks", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: " with a plan\n- Book flights\n- Reserve the hotel", + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip" }]); + editor.selectText(blockId, 4, 4); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " with a plan", + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "Book flights", + blockType: "bulletListItem", + }), + expect.objectContaining({ + text: "Reserve the hotel", + blockType: "bulletListItem", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(editor.getBlock(blockId)?.textContent()).toBe("Trip with a plan"); + expect(secondBlock?.type).toBe("bulletListItem"); + expect(secondBlock?.textContent()).toBe("Book flights"); + expect(thirdBlock?.type).toBe("bulletListItem"); + expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); + expect(editor.selection).toMatchObject({ + type: "text", + isCollapsed: true, + focus: { + blockId: thirdBlock?.id, + offset: 17, + }, + }); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts new file mode 100644 index 0000000..0e417d6 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part6.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("preserves a leading newline when a continuation starts with markdown blocks", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "\n- Book flights\n- Reserve the hotel", + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); + editor.selectText(blockId, 9, 9); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 2, + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe(""); + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "Book flights", + blockType: "bulletListItem", + }), + expect.objectContaining({ + text: "Reserve the hotel", + blockType: "bulletListItem", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan"); + expect(secondBlock?.type).toBe("bulletListItem"); + expect(secondBlock?.textContent()).toBe("Book flights"); + expect(thirdBlock?.type).toBe("bulletListItem"); + expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); + + editor.destroy(); + }); + + it("builds continuation context from the newly inserted block after structured accept", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + let secondPrompt = ""; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream(options) { + callCount += 1; + if (callCount === 1) { + yield { + type: "text-delta" as const, + delta: "\n- Book flights", + }; + yield { type: "done" as const }; + return; + } + if (callCount === 2) { + secondPrompt = String(options.messages[1]?.content ?? ""); + yield { + type: "text-delta" as const, + delta: "\n- Reserve the hotel", + }; + yield { type: "done" as const }; + return; + } + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); + editor.selectText(blockId, 9, 9); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 1, + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.[0]?.text === + "Reserve the hotel", + ); + + expect(secondPrompt).toContain("block_type=bulletListItem"); + expect(secondPrompt).toContain('prefix="Book flights"'); + + editor.destroy(); + }); + + it("treats multiline prose continuations as appended paragraph blocks", async () => { + let activeEditor: ReturnType | null = null; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: + " with notes\nBook flights this week.\nReserve the hotel before Friday.", + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); + editor.selectText(blockId, 9, 9); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => inlineCompletion?.getState().visibleSuggestion?.text === " with notes", + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: "Book flights this week.", + blockType: "paragraph", + }), + expect.objectContaining({ + text: "Reserve the hotel before Friday.", + blockType: "paragraph", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan with notes"); + expect(secondBlock?.type).toBe("paragraph"); + expect(secondBlock?.textContent()).toBe("Book flights this week."); + expect(thirdBlock?.type).toBe("paragraph"); + expect(thirdBlock?.textContent()).toBe("Reserve the hotel before Friday."); + + editor.destroy(); + }); + + it("converts deep single-line prose continuations into appended paragraph blocks", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream() { + callCount += 1; + if (callCount === 1) { + yield { + type: "text-delta" as const, + delta: " find his family waiting for him.", + }; + yield { type: "done" as const }; + return; + } + if (callCount === 2) { + yield { + type: "text-delta" as const, + delta: + ", but they were not the welcoming party he had expected. Instead, he found them in a state of distress, with worried expressions on their faces.", + }; + yield { type: "done" as const }; + return; + } + yield { + type: "text-delta" as const, + delta: + ' He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers. For a moment, no one spoke, and the silence made the room feel even heavier. Then his mother stepped forward and told him everything that had changed while he was away.', + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ + type: "insert-text", + blockId, + offset: 0, + text: "He came home to", + }]); + editor.selectText(blockId, 16, 16); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + " find his family waiting for him.", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text?.includes( + "welcoming party", + ) === true, + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) > 0, + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + text: expect.stringContaining( + "For a moment, no one spoke, and the silence made the room feel even heavier.", + ), + blockType: "paragraph", + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + const thirdBlock = secondBlock?.next; + expect(secondBlock?.type).toBe("paragraph"); + expect(secondBlock?.textContent()).toContain( + "Instead, he found them in a state of distress, with worried expressions on their faces.", + ); + expect(secondBlock?.textContent()).toContain( + 'He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers.', + ); + expect(thirdBlock?.type).toBe("paragraph"); + expect(thirdBlock?.textContent()).toContain( + "For a moment, no one spoke, and the silence made the room feel even heavier.", + ); + expect(thirdBlock?.textContent()).toContain( + "Then his mother stepped forward and told him everything that had changed while he was away.", + ); + + editor.destroy(); + }); + +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts new file mode 100644 index 0000000..85c31c7 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.part7.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + createEditor, + getInlineCompletionController, +} from "@pen/core"; +import { FIELD_EDITOR_SLOT_KEY, defineExtension } from "@pen/types"; +import { + autocompleteExtension, + createAutocompleteProvider, + getAutocompleteController, +} from "../index"; + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition was not met in time."); +} + +describe("@pen/ai-autocomplete", () => { + it("promotes long depth-two prose continuations into a new paragraph earlier", async () => { + let activeEditor: ReturnType | null = null; + let callCount = 0; + const fieldEditor = { + focusBlockId: null as string | null, + isEditing: true, + isFocused: true, + isComposing: false, + }; + const editor = createEditor({ + extensions: [ + autocompleteExtension({ + debounceMs: 0, + prefetchAfterAccept: true, + model: { + async *stream() { + callCount += 1; + if (callCount === 1) { + yield { + type: "text-delta" as const, + delta: '", tired from a long day at work."', + }; + yield { type: "done" as const }; + return; + } + yield { + type: "text-delta" as const, + delta: + '", but happy to be back. He looked forward to a quiet evening at home, away from the hustle and bustle of the office."', + }; + yield { type: "done" as const }; + }, + }, + }), + defineExtension({ + name: "test-field-editor-slot", + activateClient: async ({ editor: nextEditor }) => { + activeEditor = nextEditor; + nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); + }, + deactivateClient: async () => { + activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); + activeEditor = null; + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + fieldEditor.focusBlockId = blockId; + editor.apply([{ + type: "insert-text", + blockId, + offset: 0, + text: "He came home ", + }]); + editor.selectText(blockId, 13, 13); + + const controller = getAutocompleteController(editor); + const inlineCompletion = getInlineCompletionController(editor); + expect(controller?.request({ explicit: true })).toBe(true); + await waitForCondition( + () => + inlineCompletion?.getState().visibleSuggestion?.text === + "tired from a long day at work.", + ); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + await waitForCondition( + () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) === 1, + ); + + expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ + expect.objectContaining({ + blockType: "paragraph", + text: expect.stringContaining( + "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", + ), + }), + ]); + + expect(controller?.acceptVisibleSuggestion()).toBe(true); + + const secondBlock = editor.getBlock(blockId)?.next; + expect(secondBlock?.type).toBe("paragraph"); + expect(secondBlock?.textContent()).toContain( + "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", + ); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts index b9e362d..b61dde4 100644 --- a/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts +++ b/packages/extensions/ai-autocomplete/src/__tests__/extension.test.ts @@ -405,1829 +405,4 @@ describe("@pen/ai-autocomplete", () => { editor.destroy(); }); - it("accepts the whole visible suggestion and places the caret at the end", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot( - FIELD_EDITOR_SLOT_KEY, - fieldEditor, - ); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([ - { type: "insert-text", blockId, offset: 0, text: "Hello" }, - ]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller).toBeTruthy(); - expect(inlineCompletion).toBeTruthy(); - - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " world from pen", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); - expect(editor.selection).toMatchObject({ - type: "text", - isCollapsed: true, - focus: { - blockId, - offset: 20, - }, - }); - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - editor.destroy(); - }); - - it("anchors end-of-line suggestions to the previous character for rendering", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world!" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world!", - ); - - expect(inlineCompletion?.buildDecorations()).toEqual([ - expect.objectContaining({ - blockId, - from: 4, - to: 5, - attributes: expect.objectContaining({ - "data-suggestion-placement": "after", - }), - }), - ]); - - editor.destroy(); - }); - - it("adds a separating space to prose suggestions when the model omits it", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "today, with more detail" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); - editor.selectText(blockId, 11, 11); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " today, with more detail", - ); - - editor.destroy(); - }); - - it("rejects tiny single-word prose suggestions", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "go" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello there" }]); - editor.selectText(blockId, 11, 11); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition(() => controller?.getState().status === "idle"); - - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - expect(controller?.getState().visibleSuggestionId).toBeNull(); - - editor.destroy(); - }); - - it("accepts the full remaining completion in one step when full acceptance is enabled", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - acceptanceStrategy: "full", - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - expect(controller?.getState().settings.acceptanceStrategy).toBe("full"); - expect(controller?.acceptVisibleSuggestion()).toBe(true); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - expect(controller?.getState().metrics.acceptCount).toBe(1); - - editor.destroy(); - }); - - it("keeps scheduled requests alive across selection sync events", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 10, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request()).toBe(true); - expect(controller?.getState().status).toBe("scheduled"); - - editor.selectText(blockId, 5, 5); - - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - expect(controller?.getState().metrics.successCount).toBe(1); - - editor.destroy(); - }); - - it("dismisses visible suggestions when the selection changes after showing", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - editor.selectText(blockId, 0, 0); - - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "selection-change", - ); - - editor.destroy(); - }); - - it("keeps visible suggestions when selection-change keeps the same caret", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - editor.selectText(blockId, 5, 5); - - expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( - " world from pen", - ); - expect(controller?.getState().visibleSuggestionId).not.toBeNull(); - expect(controller?.getState().status).toBe("showing"); - - editor.destroy(); - }); - - it("drops stale results and records the stale dismissal reason", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - staleAfterMs: 1, - model: { - async *stream() { - await new Promise((resolve) => setTimeout(resolve, 5)); - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => controller?.getState().metrics.staleDropCount === 1, - ); - - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().diagnostics.lastDismissReason).toBe("stale"); - - editor.destroy(); - }); - - it("blocks requests in code blocks when the block policy disables them", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowInCodeBlocks: false, - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " never runs" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(false); - expect(modelCalled).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - - editor.destroy(); - }); - - it("respects allowed block type policies before scheduling a request", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowedBlockTypes: ["heading"], - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " blocked" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(false); - expect(modelCalled).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "block-type-not-allowed", - ); - - editor.destroy(); - }); - - it("updates block policy at runtime without recreating the controller", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowInCodeBlocks: false, - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(false); - expect(controller?.request({ explicit: true })).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - - controller?.updateBlockPolicy({ allowInCodeBlocks: true }); - expect(controller?.getState().blockPolicy.allowInCodeBlocks).toBe(true); - expect(controller?.getBlockPolicy().allowInCodeBlocks).toBe(true); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition(() => modelCalled); - - editor.destroy(); - }); - - it("cancels a scheduled request when runtime policy becomes ineligible", async () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 50, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request()).toBe(true); - expect(controller?.getState().status).toBe("scheduled"); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - await new Promise((resolve) => setTimeout(resolve, 70)); - - expect(modelCalled).toBe(false); - expect(controller?.getState().status).toBe("idle"); - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( - "scheduled", - ); - expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(1); - expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); - - controller?.updateBlockPolicy({ allowInCodeBlocks: true }); - expect(controller?.request({ explicit: true })).toBe(true); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBeNull(); - - editor.destroy(); - }); - - it("cancels an in-flight request when runtime policy becomes ineligible", async () => { - let activeEditor: ReturnType | null = null; - let streamStarted = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - streamStarted = true; - await new Promise((resolve) => setTimeout(resolve, 20)); - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition(() => streamStarted); - expect(controller?.getState().status).toBe("requesting"); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(controller?.getState().status).toBe("idle"); - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().metrics.successCount).toBe(0); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "code-block-disabled", - ); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( - "requesting", - ); - expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(1); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(0); - - editor.destroy(); - }); - - it("dismisses a visible suggestion when runtime policy becomes ineligible", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: null, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " value" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const codeBlockId = crypto.randomUUID(); - editor.apply([ - { - type: "insert-block", - blockId: codeBlockId, - blockType: "codeBlock", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: codeBlockId, - offset: 0, - text: "const answer =", - }, - ]); - fieldEditor.focusBlockId = codeBlockId; - editor.selectText(codeBlockId, 14, 14); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => controller?.getState().visibleSuggestionId !== null, - ); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - - expect(controller?.getState().visibleSuggestionId).toBeNull(); - expect(controller?.getState().status).toBe("idle"); - expect(controller?.hasVisibleSuggestion()).toBe(false); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - expect(controller?.getState().diagnostics.lastPolicyInvalidationStage).toBe( - "showing", - ); - expect(controller?.getState().metrics.policyInvalidationScheduledCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationRequestingCount).toBe(0); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(1); - - const controllerImpl = controller as unknown as { - _state: { - blockPolicy: { - allowInCodeBlocks?: boolean; - allowInTables?: boolean; - allowedBlockTypes?: readonly string[]; - deniedBlockTypes?: readonly string[]; - }; - }; - _sequence: { - requestId: string; - blockId: string; - startOffset: number; - candidate: { - inlineText: string; - appendedBlocks: readonly unknown[]; - previewBlocks: readonly unknown[]; - }; - continuationDepth: number; - } | null; - _setState: (nextState: { - status: "showing"; - activeRequestId: string; - visibleSuggestionId: string; - }) => void; - }; - controllerImpl._state.blockPolicy = { - ...controller!.getBlockPolicy(), - allowInCodeBlocks: false, - }; - controllerImpl._sequence = { - requestId: "manual-policy-recheck", - blockId: codeBlockId, - startOffset: 14, - candidate: { - inlineText: " value", - appendedBlocks: [], - previewBlocks: [], - }, - continuationDepth: 0, - }; - controllerImpl._setState({ - status: "showing", - activeRequestId: "manual-policy-recheck", - visibleSuggestionId: "manual-policy-recheck", - }); - expect(controller?.acceptVisibleSuggestion()).toBe(false); - expect(controller?.getState().metrics.policyInvalidationShowingCount).toBe(2); - expect(controller?.getState().diagnostics.lastDismissReason).toBe( - "policy-change", - ); - - editor.destroy(); - }); - - it("blocks table-cell autocomplete when tables are disabled", () => { - let activeEditor: ReturnType | null = null; - let modelCalled = false; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - activeCellCoord: { blockId: "table-1", row: 0, col: 0 }, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - blockPolicy: { - allowInTables: false, - }, - model: { - async *stream() { - modelCalled = true; - yield { type: "text-delta" as const, delta: " cell" }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - - editor.apply([ - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: "last", - }, - ]); - fieldEditor.focusBlockId = "table-1"; - editor.selectText("table-1", 0, 0); - - const controller = getAutocompleteController(editor); - expect(controller?.request({ explicit: true })).toBe(false); - expect(modelCalled).toBe(false); - expect(controller?.getState().diagnostics.lastBlockedReason).toBe( - "table-disabled", - ); - - editor.destroy(); - }); - - it("returns defensive block policy snapshots from both getters", () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - blockPolicy: { - allowedBlockTypes: ["paragraph"], - deniedBlockTypes: ["database"], - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - - const controller = getAutocompleteController(editor); - const snapshot = controller?.getBlockPolicy(); - const stateSnapshot = controller?.getState(); - expect(snapshot).toEqual({ - allowInCodeBlocks: true, - allowInTables: false, - allowedBlockTypes: ["paragraph"], - deniedBlockTypes: ["database"], - }); - - expect(() => { - if (snapshot?.allowedBlockTypes) { - (snapshot.allowedBlockTypes as string[]).push("heading"); - } - }).toThrow(); - expect(() => { - if (stateSnapshot?.blockPolicy.allowedBlockTypes) { - (stateSnapshot.blockPolicy.allowedBlockTypes as string[]).push("callout"); - } - }).toThrow(); - - expect(controller?.getBlockPolicy().allowedBlockTypes).toEqual(["paragraph"]); - expect(controller?.getState().blockPolicy.allowedBlockTypes).toEqual([ - "paragraph", - ]); - expect(stateSnapshot?.diagnostics.lastPolicyInvalidationStage).toBeNull(); - expect(stateSnapshot?.metrics.policyInvalidationScheduledCount).toBe(0); - - editor.destroy(); - }); - - it("returns stable cached snapshots until controller state changes", () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - blockPolicy: { - allowedBlockTypes: ["paragraph"], - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - - const controller = getAutocompleteController(editor); - const firstSnapshot = controller?.getSnapshot(); - const secondSnapshot = controller?.getSnapshot(); - const firstState = controller?.getState(); - const secondState = controller?.getState(); - const firstPolicy = controller?.getBlockPolicy(); - const secondPolicy = controller?.getBlockPolicy(); - const firstProviders = controller?.listProviderDescriptors(); - const secondProviders = controller?.listProviderDescriptors(); - - expect(firstSnapshot).toBe(secondSnapshot); - expect(firstState).toBe(secondState); - expect(firstPolicy).toBe(secondPolicy); - expect(firstProviders).toBe(secondProviders); - expect(firstSnapshot?.state).toBe(firstState); - expect(firstSnapshot?.state.blockPolicy).toBe(firstPolicy); - expect(firstSnapshot?.providerDescriptors).toBe(firstProviders); - - controller?.updateBlockPolicy({ allowInCodeBlocks: false }); - - const thirdSnapshot = controller?.getSnapshot(); - const thirdState = controller?.getState(); - const thirdPolicy = controller?.getBlockPolicy(); - - expect(thirdSnapshot).not.toBe(firstSnapshot); - expect(thirdState).not.toBe(firstState); - expect(thirdPolicy).not.toBe(firstPolicy); - expect(thirdSnapshot?.state).toBe(thirdState); - expect(thirdSnapshot?.state.blockPolicy).toBe(thirdPolicy); - expect(thirdSnapshot?.providerDescriptors).toBe(firstProviders); - expect(thirdState?.blockPolicy.allowInCodeBlocks).toBe(false); - expect(thirdPolicy?.allowInCodeBlocks).toBe(false); - - editor.destroy(); - }); - - it("prefetches a continuation after accepting the current suggestion", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - const requestModes: Array = []; - let secondPrompt = ""; - let thirdPrompt = ""; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream(options) { - callCount += 1; - requestModes.push(options.requestMode); - if (callCount === 1) { - yield { type: "text-delta" as const, delta: " world from pen" }; - yield { type: "done" as const }; - return; - } - if (callCount === 2) { - secondPrompt = String(options.messages[1]?.content ?? ""); - yield { - type: "text-delta" as const, - delta: - ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - }; - yield { type: "done" as const }; - return; - } - if (callCount === 3) { - thirdPrompt = String(options.messages[1]?.content ?? ""); - yield { - type: "text-delta" as const, - delta: - " The photos alone could fill a journal.\n\nYou should turn the trip into a full essay while the details are still vivid.\n\nStart with the beach at sunset and the best meal of the week.", - }; - yield { type: "done" as const }; - return; - } - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }]); - editor.selectText(blockId, 5, 5); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " world from pen", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - ); - expect(editor.getBlock(blockId)?.textContent()).toBe("Hello world from pen"); - expect(secondPrompt).toContain('prefix="Hello world from pen"'); - expect(secondPrompt).toContain("target_scope=finish-paragraph"); - expect(requestModes).toEqual([ - "inline-autocomplete", - "inline-autocomplete", - ]); - expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe( - ". Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " The photos alone could fill a journal.", - ); - expect(editor.getBlock(blockId)?.textContent()).toBe( - "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell.", - ); - expect(thirdPrompt).toContain( - 'prefix="Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell."', - ); - expect(thirdPrompt).toContain("target_scope=continue-across-paragraphs"); - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "You should turn the trip into a full essay while the details are still vivid.", - blockType: "paragraph", - }), - expect.objectContaining({ - text: "Start with the beach at sunset and the best meal of the week.", - blockType: "paragraph", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(secondBlock).toBeTruthy(); - expect(thirdBlock).toBeTruthy(); - expect(editor.getBlock(blockId)?.textContent()).toBe( - "Hello world from pen. Hope you had a lovely vacation in Ibiza last week and came back with great stories to tell. The photos alone could fill a journal.", - ); - expect(secondBlock?.textContent()).toBe( - "You should turn the trip into a full essay while the details are still vivid.", - ); - expect(thirdBlock?.textContent()).toBe( - "Start with the beach at sunset and the best meal of the week.", - ); - expect(editor.selection).toMatchObject({ - type: "text", - isCollapsed: true, - focus: { - blockId: thirdBlock?.id, - offset: 61, - }, - }); - expect(requestModes).toEqual([ - "inline-autocomplete", - "inline-autocomplete", - "inline-autocomplete", - ]); - expect(inlineCompletion?.getState().visibleSuggestion).toBeNull(); - - editor.destroy(); - }); - - it("accepts markdown continuation tails as structured blocks", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: " with a plan\n- Book flights\n- Reserve the hotel", - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip" }]); - editor.selectText(blockId, 4, 4); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " with a plan", - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "Book flights", - blockType: "bulletListItem", - }), - expect.objectContaining({ - text: "Reserve the hotel", - blockType: "bulletListItem", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(editor.getBlock(blockId)?.textContent()).toBe("Trip with a plan"); - expect(secondBlock?.type).toBe("bulletListItem"); - expect(secondBlock?.textContent()).toBe("Book flights"); - expect(thirdBlock?.type).toBe("bulletListItem"); - expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); - expect(editor.selection).toMatchObject({ - type: "text", - isCollapsed: true, - focus: { - blockId: thirdBlock?.id, - offset: 17, - }, - }); - - editor.destroy(); - }); - - it("preserves a leading newline when a continuation starts with markdown blocks", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "\n- Book flights\n- Reserve the hotel", - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); - editor.selectText(blockId, 9, 9); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 2, - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.text).toBe(""); - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "Book flights", - blockType: "bulletListItem", - }), - expect.objectContaining({ - text: "Reserve the hotel", - blockType: "bulletListItem", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan"); - expect(secondBlock?.type).toBe("bulletListItem"); - expect(secondBlock?.textContent()).toBe("Book flights"); - expect(thirdBlock?.type).toBe("bulletListItem"); - expect(thirdBlock?.textContent()).toBe("Reserve the hotel"); - - editor.destroy(); - }); - - it("builds continuation context from the newly inserted block after structured accept", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - let secondPrompt = ""; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream(options) { - callCount += 1; - if (callCount === 1) { - yield { - type: "text-delta" as const, - delta: "\n- Book flights", - }; - yield { type: "done" as const }; - return; - } - if (callCount === 2) { - secondPrompt = String(options.messages[1]?.content ?? ""); - yield { - type: "text-delta" as const, - delta: "\n- Reserve the hotel", - }; - yield { type: "done" as const }; - return; - } - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); - editor.selectText(blockId, 9, 9); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length === 1, - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.[0]?.text === - "Reserve the hotel", - ); - - expect(secondPrompt).toContain("block_type=bulletListItem"); - expect(secondPrompt).toContain('prefix="Book flights"'); - - editor.destroy(); - }); - - it("treats multiline prose continuations as appended paragraph blocks", async () => { - let activeEditor: ReturnType | null = null; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: - " with notes\nBook flights this week.\nReserve the hotel before Friday.", - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Trip plan" }]); - editor.selectText(blockId, 9, 9); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => inlineCompletion?.getState().visibleSuggestion?.text === " with notes", - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: "Book flights this week.", - blockType: "paragraph", - }), - expect.objectContaining({ - text: "Reserve the hotel before Friday.", - blockType: "paragraph", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(editor.getBlock(blockId)?.textContent()).toBe("Trip plan with notes"); - expect(secondBlock?.type).toBe("paragraph"); - expect(secondBlock?.textContent()).toBe("Book flights this week."); - expect(thirdBlock?.type).toBe("paragraph"); - expect(thirdBlock?.textContent()).toBe("Reserve the hotel before Friday."); - - editor.destroy(); - }); - - it("converts deep single-line prose continuations into appended paragraph blocks", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream() { - callCount += 1; - if (callCount === 1) { - yield { - type: "text-delta" as const, - delta: " find his family waiting for him.", - }; - yield { type: "done" as const }; - return; - } - if (callCount === 2) { - yield { - type: "text-delta" as const, - delta: - ", but they were not the welcoming party he had expected. Instead, he found them in a state of distress, with worried expressions on their faces.", - }; - yield { type: "done" as const }; - return; - } - yield { - type: "text-delta" as const, - delta: - ' He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers. For a moment, no one spoke, and the silence made the room feel even heavier. Then his mother stepped forward and told him everything that had changed while he was away.', - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ - type: "insert-text", - blockId, - offset: 0, - text: "He came home to", - }]); - editor.selectText(blockId, 16, 16); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - " find his family waiting for him.", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text?.includes( - "welcoming party", - ) === true, - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) > 0, - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - text: expect.stringContaining( - "For a moment, no one spoke, and the silence made the room feel even heavier.", - ), - blockType: "paragraph", - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - const thirdBlock = secondBlock?.next; - expect(secondBlock?.type).toBe("paragraph"); - expect(secondBlock?.textContent()).toContain( - "Instead, he found them in a state of distress, with worried expressions on their faces.", - ); - expect(secondBlock?.textContent()).toContain( - 'He approached them cautiously, his heart beginning to pound. "What happened?" he asked, scanning each of their faces for answers.', - ); - expect(thirdBlock?.type).toBe("paragraph"); - expect(thirdBlock?.textContent()).toContain( - "For a moment, no one spoke, and the silence made the room feel even heavier.", - ); - expect(thirdBlock?.textContent()).toContain( - "Then his mother stepped forward and told him everything that had changed while he was away.", - ); - - editor.destroy(); - }); - - it("promotes long depth-two prose continuations into a new paragraph earlier", async () => { - let activeEditor: ReturnType | null = null; - let callCount = 0; - const fieldEditor = { - focusBlockId: null as string | null, - isEditing: true, - isFocused: true, - isComposing: false, - }; - const editor = createEditor({ - extensions: [ - autocompleteExtension({ - debounceMs: 0, - prefetchAfterAccept: true, - model: { - async *stream() { - callCount += 1; - if (callCount === 1) { - yield { - type: "text-delta" as const, - delta: '", tired from a long day at work."', - }; - yield { type: "done" as const }; - return; - } - yield { - type: "text-delta" as const, - delta: - '", but happy to be back. He looked forward to a quiet evening at home, away from the hustle and bustle of the office."', - }; - yield { type: "done" as const }; - }, - }, - }), - defineExtension({ - name: "test-field-editor-slot", - activateClient: async ({ editor: nextEditor }) => { - activeEditor = nextEditor; - nextEditor.internals.setSlot(FIELD_EDITOR_SLOT_KEY, fieldEditor); - }, - deactivateClient: async () => { - activeEditor?.internals.setSlot(FIELD_EDITOR_SLOT_KEY, null); - activeEditor = null; - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - fieldEditor.focusBlockId = blockId; - editor.apply([{ - type: "insert-text", - blockId, - offset: 0, - text: "He came home ", - }]); - editor.selectText(blockId, 13, 13); - - const controller = getAutocompleteController(editor); - const inlineCompletion = getInlineCompletionController(editor); - expect(controller?.request({ explicit: true })).toBe(true); - await waitForCondition( - () => - inlineCompletion?.getState().visibleSuggestion?.text === - "tired from a long day at work.", - ); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - await waitForCondition( - () => (inlineCompletion?.getState().visibleSuggestion?.previewBlocks?.length ?? 0) === 1, - ); - - expect(inlineCompletion?.getState().visibleSuggestion?.previewBlocks).toEqual([ - expect.objectContaining({ - blockType: "paragraph", - text: expect.stringContaining( - "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", - ), - }), - ]); - - expect(controller?.acceptVisibleSuggestion()).toBe(true); - - const secondBlock = editor.getBlock(blockId)?.next; - expect(secondBlock?.type).toBe("paragraph"); - expect(secondBlock?.textContent()).toContain( - "He looked forward to a quiet evening at home, away from the hustle and bustle of the office.", - ); - - editor.destroy(); - }); }); diff --git a/packages/extensions/ai-autocomplete/src/__tests__/structuredCandidate.test.ts b/packages/extensions/ai-autocomplete/src/__tests__/structuredCandidate.test.ts new file mode 100644 index 0000000..33da8b8 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/__tests__/structuredCandidate.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { createAutocompleteStructuredCandidate } from "../structuredCandidate"; + +describe("createAutocompleteStructuredCandidate", () => { + it("uses single newlines for adjacent paragraph blocks", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "Hey Oleksandr,\nHappy to set that up.\n- Krijn", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe("Hey Oleksandr,"); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual([ + "Happy to set that up.", + "- Krijn", + ]); + + editor.destroy(); + }); + + it("preserves blank-line paragraph separators from double newlines", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "Hey Oleksandr,\n\nHappy to set that up.\n\n- Krijn", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe("Hey Oleksandr,"); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual([ + "", + "Happy to set that up.", + "", + "- Krijn", + ]); + + editor.destroy(); + }); + + it("uses trailing single newlines to leave the caret in a new empty block", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "Hey Oleksandr,\n", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe("Hey Oleksandr,"); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual([""]); + + editor.destroy(); + }); + + it("uses trailing double newlines to preserve a spacer before the next insertion target", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "Hey Oleksandr,\n\n", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe("Hey Oleksandr,"); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual(["", ""]); + + editor.destroy(); + }); + + it("uses leading single newlines to start appended blocks", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "\nSure thing – I can share that repo.\n- Krijn", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe(""); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual([ + "Sure thing – I can share that repo.", + "- Krijn", + ]); + + editor.destroy(); + }); + + it("uses leading double newlines to insert a spacer before appended blocks", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "\n\nSure thing – I can share that repo.\n\n- Krijn", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe(""); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual([ + "", + "Sure thing – I can share that repo.", + "", + "- Krijn", + ]); + + editor.destroy(); + }); + + it("keeps markdown list continuations structured", () => { + const editor = createEditor(); + + const candidate = createAutocompleteStructuredCandidate( + editor, + "\n- item one\n- item two", + { + activeBlockType: "paragraph", + }, + ); + + expect(candidate.inlineText).toBe(""); + expect(candidate.appendedBlocks.map((block) => block.type)).toEqual([ + "bulletListItem", + "bulletListItem", + ]); + expect(candidate.appendedBlocks.map((block) => block.content ?? "")).toEqual([ + "item one", + "item two", + ]); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts b/packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts new file mode 100644 index 0000000..4cabdf0 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteCompletionText.ts @@ -0,0 +1,275 @@ +import type { ModelStreamEvent } from "@pen/types"; +import type { AutocompleteRequestContext } from "./types"; +import { previewAutocompleteTextForLog } from "./autocompleteDebug"; + +const PROSE_BLOCK_TYPES = new Set([ + "paragraph", + "heading", + "blockquote", + "callout", +]); +const MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS = 3; + +export function handleModelEvent( + event: ModelStreamEvent, + onTextDelta: (delta: string) => void, +): boolean { + if (event.type === "text-delta") { + onTextDelta(event.delta); + return true; + } + if (event.type === "done" || event.type === "error") { + return false; + } + return true; +} + +export function normalizeCompletionText( + context: AutocompleteRequestContext, + text: string, +): string { + const normalized = text.replace(/\r/g, ""); + const withoutFence = normalized + .replace(/^```[a-zA-Z0-9_-]*\n?/, "") + .replace(/```$/, ""); + const withoutWrappedQuotes = stripWrappedCompletionQuotes( + context, + withoutFence, + ); + const trimmedLeading = + withoutWrappedQuotes.startsWith("\n\n") || + startsWithStructuredBlockContinuation(withoutWrappedQuotes) + ? withoutWrappedQuotes + : withoutWrappedQuotes.replace(/^\s*\n/, ""); + if (!trimmedLeading) { + return ""; + } + let candidate = trimmedLeading; + const suffixEcho = longestCommonPrefix(context.suffixText, trimmedLeading); + if (suffixEcho.length > 0) { + candidate = trimmedLeading.slice(suffixEcho.length); + } else if (context.suffixText.length === 0) { + const prefixEcho = longestSuffixPrefixOverlap( + context.prefixText, + trimmedLeading, + ); + if (prefixEcho.length > 0) { + candidate = trimmedLeading.slice(prefixEcho.length); + } + } + candidate = maybeInsertMissingBoundarySpace(context, candidate); + candidate = stripLeadingBoundaryPunctuationArtifacts(context, candidate); + candidate = collapseDuplicateBoundaryWhitespace(context, candidate); + candidate = maybeCapitalizeSentenceStart(context, candidate); + if (shouldRejectLowQualityCompletion(context, candidate)) { + return ""; + } + return candidate; +} + +function startsWithStructuredBlockContinuation(text: string): boolean { + return /^\s*\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test( + text, + ); +} + +function longestCommonPrefix(left: string, right: string): string { + const maxLength = Math.min(left.length, right.length); + let index = 0; + while (index < maxLength && left[index] === right[index]) { + index += 1; + } + return left.slice(0, index); +} + +function longestSuffixPrefixOverlap(left: string, right: string): string { + const maxLength = Math.min(left.length, right.length); + for (let length = maxLength; length > 0; length -= 1) { + const overlap = right.slice(0, length); + if (left.endsWith(overlap)) { + return overlap; + } + } + return ""; +} + +function maybeInsertMissingBoundarySpace( + context: AutocompleteRequestContext, + completion: string, +): string { + if ( + !completion || + context.suffixText.length > 0 || + !PROSE_BLOCK_TYPES.has(context.blockType ?? "") + ) { + return completion; + } + const lastPrefixChar = context.prefixText.slice(-1); + const firstCompletionChar = completion[0]; + if ( + !isWordLikeChar(lastPrefixChar) || + !isWordLikeChar(firstCompletionChar) + ) { + return completion; + } + if (!hasLikelyWordBoundary(completion)) { + return completion; + } + const leadingWord = completion.match(/^[A-Za-z0-9_'-]+/)?.[0] ?? ""; + if (leadingWord.length > 0 && leadingWord.length <= 2) { + return completion; + } + return ` ${completion}`; +} + +function stripWrappedCompletionQuotes( + context: AutocompleteRequestContext, + completion: string, +): string { + if (!completion || context.suffixText.length > 0) { + return completion; + } + const trimmed = completion.trim(); + if (trimmed.length < 2 || isLikelyInsideOpenQuote(context.prefixText)) { + return completion; + } + const unwrapped = unwrapMatchingQuotes(trimmed); + if (unwrapped == null) { + return completion; + } + const leadingWhitespace = completion.match(/^\s*/)?.[0] ?? ""; + const trailingWhitespace = completion.match(/\s*$/)?.[0] ?? ""; + return `${leadingWhitespace}${unwrapped}${trailingWhitespace}`; +} + +function stripLeadingBoundaryPunctuationArtifacts( + context: AutocompleteRequestContext, + completion: string, +): string { + if ( + !completion || + context.suffixText.length > 0 || + !PROSE_BLOCK_TYPES.has(context.blockType ?? "") + ) { + return completion; + } + const prefixEndsWithWhitespace = /\s$/.test(context.prefixText); + const prefixEndsSentence = /[.!?]["')\]]*\s*$/.test(context.prefixText); + if (!prefixEndsWithWhitespace && !prefixEndsSentence) { + return completion; + } + if (prefixEndsWithWhitespace) { + return completion.replace(/^([ \t]*)([,.;:!?]+)(?=\s|["'A-Z])/u, "$1"); + } + if (prefixEndsSentence) { + return completion.replace(/^([ \t]*)([,;:]+)(?=\s|["'A-Z])/u, "$1"); + } + return completion; +} + +function collapseDuplicateBoundaryWhitespace( + context: AutocompleteRequestContext, + completion: string, +): string { + if (!completion || context.suffixText.length > 0) { + return completion; + } + if (!/\s$/.test(context.prefixText)) { + return completion; + } + return completion.replace(/^[ \t]+/u, ""); +} + +function maybeCapitalizeSentenceStart( + context: AutocompleteRequestContext, + completion: string, +): string { + if ( + !completion || + context.suffixText.length > 0 || + !PROSE_BLOCK_TYPES.has(context.blockType ?? "") || + !/[.!?]["')\]]*\s*$/.test(context.prefixText) + ) { + return completion; + } + return completion.replace( + /^(\s*["'([{“‘-]*)([a-z])/u, + (_, prefix: string, character: string) => + `${prefix}${character.toUpperCase()}`, + ); +} + +function shouldRejectLowQualityCompletion( + context: AutocompleteRequestContext, + completion: string, +): boolean { + const trimmed = completion.trim(); + if (!trimmed) { + return true; + } + if ( + PROSE_BLOCK_TYPES.has(context.blockType ?? "") && + context.suffixText.length === 0 && + countWordLikeTokens(trimmed) === 1 && + trimmed.length < MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS && + !/[.!?]$/.test(trimmed) + ) { + // Single-character or two-character prose guesses tend to feel like flicker. + // Allow short but still meaningful continuations such as "cat", "the", or "and". + return true; + } + return false; +} + +function countWordLikeTokens(value: string): number { + return value.match(/[A-Za-z0-9_'-]+/g)?.length ?? 0; +} + +function hasLikelyWordBoundary(value: string): boolean { + return /[\s.,!?;:]/.test(value.slice(1)); +} + +function isWordLikeChar(value: string): boolean { + return /[A-Za-z0-9]/.test(value); +} + +function unwrapMatchingQuotes(value: string): string | null { + const quotePairs: Array<[string, string]> = [ + ['"', '"'], + ["'", "'"], + ["“", "”"], + ["‘", "’"], + ]; + for (const [open, close] of quotePairs) { + if (value.startsWith(open) && value.endsWith(close)) { + const inner = value + .slice(open.length, value.length - close.length) + .trim(); + return inner.length > 0 ? inner : null; + } + } + return null; +} + +function isLikelyInsideOpenQuote(value: string): boolean { + const asciiDoubleQuotes = value.match(/"/g)?.length ?? 0; + const asciiSingleQuotes = value.match(/'/g)?.length ?? 0; + const smartOpenQuotes = value.match(/“/g)?.length ?? 0; + const smartCloseQuotes = value.match(/”/g)?.length ?? 0; + const smartOpenSingles = value.match(/‘/g)?.length ?? 0; + const smartCloseSingles = value.match(/’/g)?.length ?? 0; + return ( + asciiDoubleQuotes % 2 === 1 || + asciiSingleQuotes % 2 === 1 || + smartOpenQuotes > smartCloseQuotes || + smartOpenSingles > smartCloseSingles + ); +} + +export function head(value: string, maxChars: number): string { + return value.length <= maxChars ? value : value.slice(0, maxChars); +} + +export function tail(value: string, maxChars: number): string { + return value.length <= maxChars ? value : value.slice(-maxChars); +} diff --git a/packages/extensions/ai-autocomplete/src/autocompleteController.ts b/packages/extensions/ai-autocomplete/src/autocompleteController.ts new file mode 100644 index 0000000..a6cafbe --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteController.ts @@ -0,0 +1,6 @@ +import "./autocompleteControllerLifecycle"; +import "./autocompleteControllerRequest"; +import "./autocompleteControllerContinuation"; +import "./autocompleteControllerState"; + +export { AutocompleteControllerImpl } from "./autocompleteControllerCore"; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts new file mode 100644 index 0000000..70471c8 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerContinuation.ts @@ -0,0 +1,245 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype._showSequenceSuggestion = function _showSequenceSuggestion(this: AutocompleteControllerRuntime): void { + const sequence = this._continuation.sequence; + if (!sequence) { + return; + } + const suggestionId = sequence.requestId; + const preview = sequence.candidate; + this._inlineCompletion.showSuggestion({ + id: suggestionId, + blockId: sequence.blockId, + offset: sequence.startOffset, + text: preview.inlineText, + type: "inline", + previewBlocks: preview.previewBlocks, + accept: () => + this._acceptFullVisibleSuggestion({ + activateContinuation: true, + }), + }); + this._setState({ + status: "showing", + activeRequestId: sequence.requestId, + visibleSuggestionId: suggestionId, + }); +} +; +ControllerPrototype._startPrefetchForAcceptedContinuation = function _startPrefetchForAcceptedContinuation(this: AutocompleteControllerRuntime, options: { + sourceRequestId: string; + blockId: string; + startOffset: number; + continuationDepth: number; +}): void { + if (!this._prefetchAfterAccept) { + return; + } + const context = this._buildContextForPosition( + options.blockId, + options.startOffset, + ); + if (!context) { + return; + } + this._prefetchAbortController?.abort(); + const abortController = new AbortController(); + this._prefetchAbortController = abortController; + void this._runPrefetchRequest({ + abortController, + context, + continuationDepth: options.continuationDepth, + sourceRequestId: options.sourceRequestId, + }); +} +; +ControllerPrototype._runPrefetchRequest = async function _runPrefetchRequest(this: AutocompleteControllerRuntime, options: { + abortController: AbortController; + context: AutocompleteRequestContext; + continuationDepth: number; + sourceRequestId: string; +}): Promise { + if (!this._model) { + return; + } + const { abortController, context, continuationDepth, sourceRequestId } = + options; + const requestId = crypto.randomUUID(); + const { messages } = await buildAutocompleteMessages({ + context, + registry: this._providerRegistry, + maxProviderChars: this._maxProviderChars, + maxProviderTimeMs: this._maxProviderTimeMs, + mode: "continuation", + continuationDepth, + }); + if (abortController.signal.aborted) { + return; + } + + let text = ""; + try { + for await (const event of this._model.stream({ + messages, + tools: [], + signal: abortController.signal, + requestMode: AUTOCOMPLETE_REQUEST_MODE, + })) { + if (abortController.signal.aborted) { + return; + } + if ( + !handleModelEvent(event, (delta) => { + text += delta; + }) + ) { + break; + } + } + } catch { + return; + } + + if (abortController.signal.aborted) { + return; + } + const normalizedText = normalizeCompletionText(context, text); + if (!normalizedText) { + logAutocompleteEvent("prefetch produced empty normalized text", { + requestId, + sourceRequestId, + blockType: context.blockType, + rawLength: text.length, + rawPreview: previewAutocompleteTextForLog(text), + }); + return; + } + const candidate = createAutocompleteStructuredCandidate( + this._editor, + normalizedText, + { + activeBlockType: context.blockType, + continuationDepth, + }, + ); + logAutocompleteEvent("prefetch produced suggestion", { + requestId, + sourceRequestId, + blockType: context.blockType, + rawLength: text.length, + rawPreview: previewAutocompleteTextForLog(text), + normalizedLength: normalizedText.length, + normalizedPreview: previewAutocompleteTextForLog(normalizedText), + inlineLength: candidate.inlineText.length, + inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), + appendedBlockCount: candidate.appendedBlocks.length, + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), + previewBlockCount: candidate.previewBlocks.length, + }); + this._continuation.setPrefetchedContinuation({ + sourceRequestId, + requestId, + blockId: context.blockId, + startOffset: context.offset, + candidate, + continuationDepth, + }); + this._activatePendingAcceptedContinuation(); +} +; +ControllerPrototype._activatePendingAcceptedContinuation = function _activatePendingAcceptedContinuation(this: AutocompleteControllerRuntime): boolean { + if ( + !this._continuation.activatePendingAcceptedContinuation( + this._editor.selection, + ) + ) { + return false; + } + this._showSequenceSuggestion(); + return true; +} +; +ControllerPrototype._clearSequence = function _clearSequence(this: AutocompleteControllerRuntime): void { + this._continuation.clearSequence(); +} +; +ControllerPrototype._clearVisibleSuggestionAfterAccept = function _clearVisibleSuggestionAfterAccept(this: AutocompleteControllerRuntime): void { + this._clearSequence(); + this._setState({ + status: "idle", + activeRequestId: null, + visibleSuggestionId: null, + diagnostics: { + ...this._state.diagnostics, + lastDismissReason: "accept", + }, + }); + this._inlineCompletion.dismissSuggestion(); +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts new file mode 100644 index 0000000..e04d3f6 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerCore.ts @@ -0,0 +1,241 @@ +import type { + Editor, + FieldEditor, + InlineCompletionController, + ModelAdapter, +} from "@pen/types"; +import { getOpOriginType } from "@pen/types"; +import { + DEFAULT_DEBOUNCE_MS, + DEFAULT_ACCEPTANCE_STRATEGY, + DEFAULT_MAX_NEIGHBOR_CHARS, + DEFAULT_MAX_PREFIX_CHARS, + DEFAULT_MAX_PROVIDER_CHARS, + DEFAULT_MAX_PROVIDER_TIME_MS, + DEFAULT_MAX_SUFFIX_CHARS, + DEFAULT_PREFETCH_AFTER_ACCEPT, + DEFAULT_STALE_AFTER_MS, +} from "./constants"; +import { builtinAutocompleteProviders } from "./providers/builtins"; +import { AutocompleteProviderRegistry } from "./providers/registry"; +import type { + AutocompleteContextProvider, + AutocompleteProviderDescriptor, +} from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteController, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { AutocompleteContinuationState } from "./continuationState"; + +export interface AutocompleteControllerImpl { + destroy(): void; + getSnapshot(): AutocompleteControllerSnapshot; + getState(): AutocompleteControllerState; + getBlockPolicy(): Readonly; + subscribe(listener: () => void): () => void; + setEnabled(enabled: boolean): void; + request(options?: { explicit?: boolean }): boolean; + acceptVisibleSuggestion(): boolean; + _acceptFullVisibleSuggestion(options?: { + activateContinuation?: boolean; + }): boolean; + hasVisibleSuggestion(): boolean; + registerProvider(provider: AutocompleteContextProvider): () => void; + listProviderDescriptors(): readonly AutocompleteProviderDescriptor[]; + updateRuntimeSettings( + settings: Partial, +): void; + updateBlockPolicy(policy: Partial): void; + dismiss(reason?: AutocompleteDismissReason): void; + _runRequest(requestId: string): Promise; + _buildContext(): AutocompleteRequestContext | null; + _buildContextForPosition( + blockId: string, + offset: number, +): AutocompleteRequestContext | null; + _shouldContinueRequest( + requestId: string, + context: AutocompleteRequestContext, +): boolean; + _shouldDismissForExternalCommit( + affectedBlocks: readonly string[], +): boolean; + _shouldDismissForSelectionChange(): boolean; + _getFieldEditor(): FieldEditor | null; + _showSequenceSuggestion(): void; + _startPrefetchForAcceptedContinuation(options: { + sourceRequestId: string; + blockId: string; + startOffset: number; + continuationDepth: number; + }): void; + _runPrefetchRequest(options: { + abortController: AbortController; + context: AutocompleteRequestContext; + continuationDepth: number; + sourceRequestId: string; + }): Promise; + _activatePendingAcceptedContinuation(): boolean; + _clearSequence(): void; + _clearVisibleSuggestionAfterAccept(): void; + _setBlockedReason(reason: AutocompleteBlockedReason): void; + _recordPolicyInvalidation( + policyFailure: AutocompleteBlockedReason, + invalidationStage: AutocompletePolicyInvalidationStage | null, +): void; + _invalidateForPolicyChange(): void; + _getActiveSelectionBlockId(): string | null; + _getPolicyInvalidationStage(): AutocompletePolicyInvalidationStage | null; + _resolveCurrentBlockFailure( + blockId: string, +): AutocompleteBlockedReason | null; + _resolveContextEligibilityFailure( + blockId: string, + blockType: string | null, +): AutocompleteBlockedReason | null; + _resolveBlockPolicyFailure( + blockType: string | null, +): AutocompleteBlockedReason | null; + _clearDebounceTimer(): void; + _setState(next: Partial): void; + _getProviderDescriptorsSnapshot(): readonly AutocompleteProviderDescriptor[]; + _invalidateSnapshot(): void; + _invalidateProviderDescriptorsSnapshot(): void; + _emit(): void; +} + +export class AutocompleteControllerImpl implements AutocompleteController { + private readonly _editor: Editor; + private readonly _model: ModelAdapter | undefined; + private _debounceMs: number; + private _acceptanceStrategy: AutocompleteAcceptanceStrategy; + private _staleAfterMs: number; + private readonly _maxPrefixChars: number; + private readonly _maxSuffixChars: number; + private readonly _maxNeighborChars: number; + private readonly _maxProviderChars: number; + private readonly _maxProviderTimeMs: number; + private _prefetchAfterAccept: boolean; + private readonly _providerRegistry: AutocompleteProviderRegistry; + private readonly _inlineCompletion: InlineCompletionController; + private readonly _listeners = new Set<() => void>(); + private _snapshot: AutocompleteControllerSnapshot | null = null; + private _providerDescriptorsSnapshot: + | readonly AutocompleteProviderDescriptor[] + | null = null; + private _state: AutocompleteControllerState = { + enabled: true, + status: "idle", + activeRequestId: null, + visibleSuggestionId: null, + settings: { + debounceMs: DEFAULT_DEBOUNCE_MS, + prefetchAfterAccept: DEFAULT_PREFETCH_AFTER_ACCEPT, + acceptanceStrategy: "full", + staleAfterMs: DEFAULT_STALE_AFTER_MS, + }, + blockPolicy: { + allowInCodeBlocks: true, + allowInTables: false, + deniedBlockTypes: ["database"], + }, + metrics: { + requestCount: 0, + successCount: 0, + cancelCount: 0, + staleDropCount: 0, + explicitTabTriggerCount: 0, + acceptCount: 0, + policyInvalidationScheduledCount: 0, + policyInvalidationRequestingCount: 0, + policyInvalidationShowingCount: 0, + }, + providerTimings: [], + diagnostics: { + lastDismissReason: null, + lastBlockedReason: null, + lastPolicyInvalidationStage: null, + }, + }; + private _debounceTimer: ReturnType | null = null; + private _abortController: AbortController | null = null; + private _unsubscribeSelection: (() => void) | null = null; + private _unsubscribeCommit: (() => void) | null = null; + private readonly _continuation = new AutocompleteContinuationState(); + private _prefetchAbortController: AbortController | null = null; + + constructor( + editor: Editor, + config: AutocompleteExtensionConfig, + services: { inlineCompletion: InlineCompletionController }, + ) { + this._editor = editor; + this._inlineCompletion = services.inlineCompletion; + this._model = config.model; + this._debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; + this._acceptanceStrategy = config.acceptanceStrategy ?? "full"; + this._staleAfterMs = config.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; + this._state.blockPolicy = { + allowInCodeBlocks: true, + allowInTables: false, + deniedBlockTypes: ["database"], + ...config.blockPolicy, + }; + this._maxPrefixChars = + config.maxPrefixChars ?? DEFAULT_MAX_PREFIX_CHARS; + this._maxSuffixChars = + config.maxSuffixChars ?? DEFAULT_MAX_SUFFIX_CHARS; + this._maxNeighborChars = + config.maxNeighborChars ?? DEFAULT_MAX_NEIGHBOR_CHARS; + this._maxProviderChars = + config.maxProviderChars ?? DEFAULT_MAX_PROVIDER_CHARS; + this._maxProviderTimeMs = + config.maxProviderTimeMs ?? DEFAULT_MAX_PROVIDER_TIME_MS; + this._prefetchAfterAccept = + config.prefetchAfterAccept ?? DEFAULT_PREFETCH_AFTER_ACCEPT; + this._providerRegistry = new AutocompleteProviderRegistry([ + ...builtinAutocompleteProviders, + ...(config.providers ?? []), + ]); + this._state.enabled = config.enabled ?? true; + this._state.settings = { + debounceMs: this._debounceMs, + prefetchAfterAccept: this._prefetchAfterAccept, + acceptanceStrategy: this._acceptanceStrategy, + staleAfterMs: this._staleAfterMs, + }; + + this._unsubscribeSelection = this._editor.onSelectionChange(() => { + if (this._shouldDismissForSelectionChange()) { + this.dismiss("selection-change"); + } + }); + this._unsubscribeCommit = this._editor.onDocumentCommit((event) => { + if (!this._state.enabled) { + return; + } + if (this._continuation.consumeAcceptedAiCommit(event.origin)) { + return; + } + const originType = getOpOriginType(event.origin); + if (originType !== "user" && originType !== "input-rule") { + if ( + this._shouldDismissForExternalCommit(event.affectedBlocks) + ) { + this.dismiss("external-edit"); + } + return; + } + this.request(); + }); + } +} diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts new file mode 100644 index 0000000..0a9b361 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerLifecycle.ts @@ -0,0 +1,427 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype.destroy = function destroy(this: AutocompleteControllerRuntime): void { + this._unsubscribeSelection?.(); + this._unsubscribeSelection = null; + this._unsubscribeCommit?.(); + this._unsubscribeCommit = null; + this._clearDebounceTimer(); + this._abortController?.abort(); + this._abortController = null; + this._prefetchAbortController?.abort(); + this._prefetchAbortController = null; + this._continuation.clearContinuations(); +} +; +ControllerPrototype.getSnapshot = function getSnapshot(this: AutocompleteControllerRuntime): AutocompleteControllerSnapshot { + if (this._snapshot === null) { + const state = cloneAutocompleteControllerState(this._state); + this._snapshot = freezeAutocompleteControllerSnapshot({ + state: freezeAutocompleteControllerState(state), + providerDescriptors: this._getProviderDescriptorsSnapshot(), + }); + } + return this._snapshot; +} +; +ControllerPrototype.getState = function getState(this: AutocompleteControllerRuntime): AutocompleteControllerState { + return this.getSnapshot().state; +} +; +ControllerPrototype.getBlockPolicy = function getBlockPolicy(this: AutocompleteControllerRuntime): Readonly { + return this.getSnapshot().state.blockPolicy; +} +; +ControllerPrototype.subscribe = function subscribe(this: AutocompleteControllerRuntime, listener: () => void): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); +} +; +ControllerPrototype.setEnabled = function setEnabled(this: AutocompleteControllerRuntime, enabled: boolean): void { + if (this._state.enabled === enabled) { + return; + } + this._state = { + ...this._state, + enabled, + status: enabled ? "idle" : "idle", + activeRequestId: null, + }; + this._invalidateSnapshot(); + if (!enabled) { + this.dismiss("disabled"); + } + this._emit(); +} +; +ControllerPrototype.request = function request(this: AutocompleteControllerRuntime, options?: { explicit?: boolean }): boolean { + if (!this._state.enabled) { + this._setBlockedReason("disabled"); + return false; + } + if (!this._model) { + this._setBlockedReason("missing-model"); + return false; + } + // Validate that autocomplete is currently eligible, but defer reading the + // exact caret context until the debounced request actually runs. + if (!this._buildContext()) { + return false; + } + this.dismiss("request-replaced"); + const requestId = crypto.randomUUID(); + this._setState({ + status: "scheduled", + activeRequestId: requestId, + metrics: { + ...this._state.metrics, + requestCount: this._state.metrics.requestCount + 1, + explicitTabTriggerCount: + this._state.metrics.explicitTabTriggerCount + + (options?.explicit ? 1 : 0), + }, + diagnostics: { + ...this._state.diagnostics, + lastBlockedReason: null, + lastPolicyInvalidationStage: null, + }, + }); + this._clearDebounceTimer(); + const delay = options?.explicit ? 0 : this._debounceMs; + logAutocompleteEvent("request scheduled", { + requestId, + explicit: options?.explicit ?? false, + debounceMs: delay, + }); + this._debounceTimer = setTimeout(() => { + void this._runRequest(requestId); + }, delay); + return true; +} +; +ControllerPrototype.acceptVisibleSuggestion = function acceptVisibleSuggestion(this: AutocompleteControllerRuntime): boolean { + const sequence = this._continuation.sequence; + if (!sequence || !this.hasVisibleSuggestion()) { + return false; + } + const policyFailure = this._resolveCurrentBlockFailure( + sequence.blockId, + ); + if (policyFailure) { + this._recordPolicyInvalidation(policyFailure, "showing"); + return false; + } + return this._acceptFullVisibleSuggestion({ + activateContinuation: true, + }); +} +; +ControllerPrototype._acceptFullVisibleSuggestion = function _acceptFullVisibleSuggestion(this: AutocompleteControllerRuntime, options?: { + activateContinuation?: boolean; +}): boolean { + const sequence = this._continuation.sequence; + if (!sequence) { + return false; + } + const candidate = sequence.candidate; + if ( + candidate.inlineText.length === 0 && + candidate.previewBlocks.length === 0 + ) { + this.dismiss(); + return false; + } + const blockId = sequence.blockId; + const requestId = sequence.requestId; + const continuationDepth = sequence.continuationDepth + 1; + const acceptanceResult = materializeStructuredCandidateAcceptance({ + blockId, + offset: sequence.startOffset, + candidate, + }); + logAutocompleteEvent("accept visible suggestion", { + requestId, + blockId, + startOffset: sequence.startOffset, + inlineLength: candidate.inlineText.length, + inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), + appendedBlockCount: candidate.appendedBlocks.length, + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), + opTypes: acceptanceResult.ops.map((op) => op.type), + nextCaretBlockId: acceptanceResult.selection.blockId, + nextCaretOffset: acceptanceResult.selection.offset, + }); + this._continuation.beginAcceptingSequenceSegment(); + this._editor.apply(acceptanceResult.ops, { + origin: "ai", + undoGroup: true, + }); + const acceptedBlock = this._editor.getBlock(blockId); + const firstNextBlock = acceptedBlock?.next ?? null; + const secondNextBlock = firstNextBlock?.next ?? null; + logAutocompleteEvent( + `accept applied summary requestId=${requestId} appendedBlockCount=${candidate.appendedBlocks.length} opTypes=${acceptanceResult.ops.map((op) => op.type).join(",")} currentBlockType=${acceptedBlock?.type ?? "missing"} currentBlockText=${previewAutocompleteTextForLog(acceptedBlock?.textContent() ?? "")} nextBlockType=${firstNextBlock?.type ?? "none"} nextBlockText=${previewAutocompleteTextForLog(firstNextBlock?.textContent() ?? "")} nextNextBlockType=${secondNextBlock?.type ?? "none"} nextNextBlockText=${previewAutocompleteTextForLog(secondNextBlock?.textContent() ?? "")}`, + ); + const nextCaretBlockId = acceptanceResult.selection.blockId; + const nextCaretOffset = acceptanceResult.selection.offset; + this._setState({ + metrics: { + ...this._state.metrics, + acceptCount: this._state.metrics.acceptCount + 1, + }, + }); + const fieldEditor = this._getFieldEditor(); + this._editor.selectText( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + if (fieldEditor) { + const programmaticFieldEditor = + fieldEditor as typeof fieldEditor & { + commitProgrammaticTextSelection?: ( + blockId: string, + anchorOffset: number, + focusOffset: number, + ) => void; + }; + if ( + typeof programmaticFieldEditor.commitProgrammaticTextSelection === + "function" + ) { + programmaticFieldEditor.commitProgrammaticTextSelection( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + } else if ( + typeof fieldEditor.activateTextSelection === "function" + ) { + fieldEditor.activateTextSelection( + nextCaretBlockId, + nextCaretOffset, + nextCaretOffset, + ); + } else if (typeof fieldEditor.activate === "function") { + fieldEditor.activate(nextCaretBlockId); + } + if (typeof fieldEditor.focus === "function") { + fieldEditor.focus(); + } + } + + if (options?.activateContinuation && this._prefetchAfterAccept) { + this._continuation.setPendingAcceptedContinuation({ + sourceRequestId: requestId, + blockId: nextCaretBlockId, + startOffset: nextCaretOffset, + continuationDepth, + }); + this._clearVisibleSuggestionAfterAccept(); + this._startPrefetchForAcceptedContinuation({ + sourceRequestId: requestId, + blockId: nextCaretBlockId, + startOffset: nextCaretOffset, + continuationDepth, + }); + } else { + this.dismiss("accept"); + } + return true; +} +; +ControllerPrototype.hasVisibleSuggestion = function hasVisibleSuggestion(this: AutocompleteControllerRuntime): boolean { + return ( + this._continuation.sequence !== null && + this._state.visibleSuggestionId !== null + ); +} +; +ControllerPrototype.registerProvider = function registerProvider(this: AutocompleteControllerRuntime, provider: AutocompleteContextProvider): () => void { + const unregister = this._providerRegistry.registerProvider(provider); + this._invalidateProviderDescriptorsSnapshot(); + this._emit(); + return () => { + unregister(); + this._invalidateProviderDescriptorsSnapshot(); + this._emit(); + }; +} +; +ControllerPrototype.listProviderDescriptors = function listProviderDescriptors(this: AutocompleteControllerRuntime) { + return this.getSnapshot().providerDescriptors; +} +; +ControllerPrototype.updateRuntimeSettings = function updateRuntimeSettings(this: AutocompleteControllerRuntime, + settings: Partial, +): void { + const nextDebounceMs = settings.debounceMs; + const nextPrefetchAfterAccept = settings.prefetchAfterAccept; + const nextAcceptanceStrategy = settings.acceptanceStrategy; + let changed = false; + + if ( + typeof nextDebounceMs === "number" && + Number.isFinite(nextDebounceMs) && + nextDebounceMs >= 0 && + nextDebounceMs !== this._debounceMs + ) { + this._debounceMs = nextDebounceMs; + changed = true; + } + + if ( + typeof nextPrefetchAfterAccept === "boolean" && + nextPrefetchAfterAccept !== this._prefetchAfterAccept + ) { + this._prefetchAfterAccept = nextPrefetchAfterAccept; + if (!nextPrefetchAfterAccept) { + this._prefetchAbortController?.abort(); + this._prefetchAbortController = null; + this._continuation.clearContinuations(); + } + changed = true; + } + + if ( + nextAcceptanceStrategy === "full" && + nextAcceptanceStrategy !== this._acceptanceStrategy + ) { + this._acceptanceStrategy = nextAcceptanceStrategy; + changed = true; + } + + const nextStaleAfterMs = settings.staleAfterMs; + if ( + typeof nextStaleAfterMs === "number" && + Number.isFinite(nextStaleAfterMs) && + nextStaleAfterMs >= 0 && + nextStaleAfterMs !== this._staleAfterMs + ) { + this._staleAfterMs = nextStaleAfterMs; + changed = true; + } + + if (!changed) { + return; + } + + this._setState({ + settings: { + debounceMs: this._debounceMs, + prefetchAfterAccept: this._prefetchAfterAccept, + acceptanceStrategy: this._acceptanceStrategy, + staleAfterMs: this._staleAfterMs, + }, + }); +} +; +ControllerPrototype.updateBlockPolicy = function updateBlockPolicy(this: AutocompleteControllerRuntime, policy: Partial): void { + const nextPolicy: AutocompleteBlockPolicy = { + ...this._state.blockPolicy, + ...policy, + }; + if (areBlockPoliciesEqual(this._state.blockPolicy, nextPolicy)) { + return; + } + this._setState({ + blockPolicy: nextPolicy, + }); + this._invalidateForPolicyChange(); +} +; +ControllerPrototype.dismiss = function dismiss(this: AutocompleteControllerRuntime, reason: AutocompleteDismissReason = "external-edit"): void { + this._clearDebounceTimer(); + const cancelledRequest = + this._state.status === "scheduled" || + this._state.status === "requesting"; + this._abortController?.abort(); + this._abortController = null; + this._prefetchAbortController?.abort(); + this._prefetchAbortController = null; + this._clearSequence(); + this._continuation.clearContinuations(); + this._setState({ + status: "idle", + activeRequestId: null, + visibleSuggestionId: null, + metrics: { + ...this._state.metrics, + cancelCount: + this._state.metrics.cancelCount + + (cancelledRequest ? 1 : 0), + }, + diagnostics: { + ...this._state.diagnostics, + lastDismissReason: reason, + }, + }); + this._inlineCompletion.dismissSuggestion(); +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts new file mode 100644 index 0000000..322519e --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerRequest.ts @@ -0,0 +1,443 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype._runRequest = async function _runRequest(this: AutocompleteControllerRuntime, requestId: string): Promise { + if (this._state.activeRequestId !== requestId || !this._model) { + logAutocompleteEvent("request skipped before start", { + requestId, + hasModel: !!this._model, + activeRequestId: this._state.activeRequestId, + }); + return; + } + this._abortController?.abort(); + const abortController = new AbortController(); + this._abortController = abortController; + const context = this._buildContext(); + if (!context) { + logAutocompleteEvent("request blocked before prompt build", { + requestId, + lastBlockedReason: this._state.diagnostics.lastBlockedReason, + }); + this._setState({ + status: "idle", + activeRequestId: null, + }); + return; + } + this._setState({ + status: "requesting", + activeRequestId: requestId, + }); + logAutocompleteEvent("request started", { + requestId, + blockId: context.blockId, + offset: context.offset, + }); + + const { messages, providerTimings } = await buildAutocompleteMessages({ + context, + registry: this._providerRegistry, + maxProviderChars: this._maxProviderChars, + maxProviderTimeMs: this._maxProviderTimeMs, + continuationDepth: 0, + }); + if (!this._shouldContinueRequest(requestId, context)) { + logAutocompleteEvent("request cancelled after prompt build", { + requestId, + activeRequestId: this._state.activeRequestId, + lastBlockedReason: this._state.diagnostics.lastBlockedReason, + }); + return; + } + this._setState({ providerTimings }); + logAutocompleteEvent("request prompt ready", { + requestId, + providerTimings, + promptLength: String(messages[1]?.content ?? "").length, + }); + const startedAt = Date.now(); + + let text = ""; + try { + logAutocompleteEvent("request model stream opening", { requestId }); + for await (const event of this._model.stream({ + messages, + tools: [], + signal: abortController.signal, + requestMode: AUTOCOMPLETE_REQUEST_MODE, + })) { + if (!this._shouldContinueRequest(requestId, context)) { + logAutocompleteEvent("request cancelled during stream", { + requestId, + activeRequestId: this._state.activeRequestId, + lastBlockedReason: + this._state.diagnostics.lastBlockedReason, + }); + abortController.abort(); + return; + } + logAutocompleteEvent("request model event", { + requestId, + type: event.type, + }); + if ( + !handleModelEvent(event, (delta) => { + text += delta; + }) + ) { + break; + } + } + } catch { + logAutocompleteEvent("request stream threw", { + requestId, + aborted: abortController.signal.aborted, + }); + if (!abortController.signal.aborted) { + this._setState({ + status: "idle", + activeRequestId: null, + }); + } + return; + } + + if (!this._shouldContinueRequest(requestId, context)) { + logAutocompleteEvent("request cancelled after stream", { + requestId, + activeRequestId: this._state.activeRequestId, + lastBlockedReason: this._state.diagnostics.lastBlockedReason, + }); + return; + } + if (Date.now() - startedAt > this._staleAfterMs) { + logAutocompleteEvent("request dropped as stale", { + requestId, + elapsedMs: Date.now() - startedAt, + staleAfterMs: this._staleAfterMs, + }); + this._setState({ + status: "idle", + activeRequestId: null, + metrics: { + ...this._state.metrics, + staleDropCount: this._state.metrics.staleDropCount + 1, + }, + diagnostics: { + ...this._state.diagnostics, + lastDismissReason: "stale", + }, + }); + return; + } + const normalizedText = normalizeCompletionText(context, text); + logAutocompleteEvent("request normalized text", { + requestId, + blockType: context.blockType, + rawLength: text.length, + rawPreview: previewAutocompleteTextForLog(text), + normalizedLength: normalizedText.length, + normalizedPreview: previewAutocompleteTextForLog(normalizedText), + }); + if (!normalizedText) { + logAutocompleteEvent("request produced empty normalized text", { + requestId, + rawLength: text.length, + }); + this._setState({ + status: "idle", + activeRequestId: null, + }); + return; + } + + const candidate = createAutocompleteStructuredCandidate( + this._editor, + normalizedText, + { + activeBlockType: context.blockType, + continuationDepth: 0, + }, + ); + this._continuation.setSequence({ + requestId, + blockId: context.blockId, + startOffset: context.offset, + candidate, + continuationDepth: 0, + }); + this._setState({ + metrics: { + ...this._state.metrics, + successCount: this._state.metrics.successCount + 1, + }, + }); + logAutocompleteEvent("request produced suggestion", { + requestId, + blockType: context.blockType, + normalizedLength: normalizedText.length, + inlineLength: candidate.inlineText.length, + inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), + appendedBlockCount: candidate.appendedBlocks.length, + appendedBlockTypes: candidate.appendedBlocks.map( + (block) => block.type, + ), + previewBlockCount: candidate.previewBlocks.length, + }); + this._showSequenceSuggestion(); +} +; +ControllerPrototype._buildContext = function _buildContext(this: AutocompleteControllerRuntime): AutocompleteRequestContext | null { + const selection = this._editor.selection; + if (selection == null) { + this._setBlockedReason("missing-context"); + return null; + } + if (selection.type !== "text") { + this._setBlockedReason("selection-not-text"); + return null; + } + if (!selection.isCollapsed) { + this._setBlockedReason("selection-not-collapsed"); + return null; + } + if (selection.isMultiBlock) { + this._setBlockedReason("selection-multi-block"); + return null; + } + const fieldEditor = this._getFieldEditor(); + if (!fieldEditor) { + this._setBlockedReason("field-editor-unavailable"); + return null; + } + if (!fieldEditor.isEditing) { + this._setBlockedReason("field-editor-not-editing"); + return null; + } + if (!fieldEditor.isFocused) { + this._setBlockedReason("field-editor-not-focused"); + return null; + } + if (fieldEditor.isComposing) { + this._setBlockedReason("field-editor-composing"); + return null; + } + return this._buildContextForPosition( + selection.focus.blockId, + selection.focus.offset, + ); +} +; +ControllerPrototype._buildContextForPosition = function _buildContextForPosition(this: AutocompleteControllerRuntime, + blockId: string, + offset: number, +): AutocompleteRequestContext | null { + const block = this._editor.getBlock(blockId); + if (!block) { + this._setBlockedReason("block-missing"); + return null; + } + const blockPolicyFailure = this._resolveContextEligibilityFailure( + block.id, + block.type, + ); + if (blockPolicyFailure) { + this._setBlockedReason(blockPolicyFailure); + return null; + } + const blockText = block.textContent(); + return { + editor: this._editor, + blockId: block.id, + blockType: block.type, + offset, + prefixText: tail(blockText.slice(0, offset), this._maxPrefixChars), + suffixText: head(blockText.slice(offset), this._maxSuffixChars), + previousBlockText: tail( + block.prev?.textContent() ?? "", + this._maxNeighborChars, + ), + nextBlockText: head( + block.next?.textContent() ?? "", + this._maxNeighborChars, + ), + }; +} +; +ControllerPrototype._shouldContinueRequest = function _shouldContinueRequest(this: AutocompleteControllerRuntime, + requestId: string, + context: AutocompleteRequestContext, +): boolean { + if (this._state.activeRequestId !== requestId) { + logAutocompleteEvent("request continuation blocked: replaced", { + requestId, + activeRequestId: this._state.activeRequestId, + }); + return false; + } + const selection = this._editor.selection; + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock || + selection.focus.blockId !== context.blockId || + selection.focus.offset !== context.offset + ) { + logAutocompleteEvent( + "request continuation blocked: selection changed", + { + requestId, + expected: { + blockId: context.blockId, + offset: context.offset, + }, + actual: + selection?.type === "text" + ? { + type: selection.type, + blockId: selection.focus.blockId, + offset: selection.focus.offset, + isCollapsed: selection.isCollapsed, + isMultiBlock: selection.isMultiBlock, + } + : selection, + }, + ); + return false; + } + const fieldEditor = this._getFieldEditor(); + if ( + !fieldEditor?.isEditing || + !fieldEditor.isFocused || + fieldEditor.isComposing + ) { + logAutocompleteEvent( + "request continuation blocked: field editor state", + { + requestId, + fieldEditor: fieldEditor + ? { + isEditing: fieldEditor.isEditing, + isFocused: fieldEditor.isFocused, + isComposing: fieldEditor.isComposing, + focusBlockId: fieldEditor.focusBlockId, + } + : null, + }, + ); + return false; + } + const block = this._editor.getBlock(context.blockId); + const policyFailure = block + ? this._resolveContextEligibilityFailure(block.id, block.type) + : "block-missing"; + if (policyFailure) { + this._setBlockedReason(policyFailure); + return false; + } + return true; +} +; +ControllerPrototype._shouldDismissForExternalCommit = function _shouldDismissForExternalCommit(this: AutocompleteControllerRuntime, + affectedBlocks: readonly string[], +): boolean { + const visibleSuggestion = + this._inlineCompletion.getState().visibleSuggestion; + return ( + !!visibleSuggestion && + affectedBlocks.includes(visibleSuggestion.blockId) + ); +} +; +ControllerPrototype._shouldDismissForSelectionChange = function _shouldDismissForSelectionChange(this: AutocompleteControllerRuntime): boolean { + const visibleSuggestion = + this._inlineCompletion.getState().visibleSuggestion; + if (!visibleSuggestion || visibleSuggestion.type !== "inline") { + return false; + } + const selection = this._editor.selection; + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { + return true; + } + return ( + selection.focus.blockId !== visibleSuggestion.blockId || + selection.focus.offset !== visibleSuggestion.offset + ); +} +; +ControllerPrototype._getFieldEditor = function _getFieldEditor(this: AutocompleteControllerRuntime): FieldEditor | null { + return ( + this._editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ) ?? null + ); +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts new file mode 100644 index 0000000..89ad1a9 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerSnapshots.ts @@ -0,0 +1,122 @@ +import type { AutocompleteBlockPolicy, AutocompleteControllerSnapshot, AutocompleteControllerState, AutocompletePolicyInvalidationStage } from "./types"; +import type { AutocompleteProviderDescriptor } from "./providers/types"; + +export function areBlockPoliciesEqual( + left: AutocompleteBlockPolicy, + right: AutocompleteBlockPolicy, +): boolean { + return ( + left.allowInCodeBlocks === right.allowInCodeBlocks && + left.allowInTables === right.allowInTables && + areStringArraysEqual(left.allowedBlockTypes, right.allowedBlockTypes) && + areStringArraysEqual(left.deniedBlockTypes, right.deniedBlockTypes) + ); +} + +function areStringArraysEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right || left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function cloneBlockPolicy( + policy: AutocompleteBlockPolicy, +): AutocompleteBlockPolicy { + return { + allowInCodeBlocks: policy.allowInCodeBlocks, + allowInTables: policy.allowInTables, + allowedBlockTypes: policy.allowedBlockTypes + ? [...policy.allowedBlockTypes] + : undefined, + deniedBlockTypes: policy.deniedBlockTypes + ? [...policy.deniedBlockTypes] + : undefined, + }; +} + +export function cloneAutocompleteControllerState( + state: AutocompleteControllerState, +): AutocompleteControllerState { + return { + enabled: state.enabled, + status: state.status, + activeRequestId: state.activeRequestId, + visibleSuggestionId: state.visibleSuggestionId, + settings: { ...state.settings }, + blockPolicy: cloneBlockPolicy(state.blockPolicy), + metrics: { ...state.metrics }, + providerTimings: state.providerTimings.map((timing) => ({ ...timing })), + diagnostics: { ...state.diagnostics }, + }; +} + +function freezeBlockPolicy( + policy: AutocompleteBlockPolicy, +): AutocompleteBlockPolicy { + if (policy.allowedBlockTypes) { + Object.freeze(policy.allowedBlockTypes); + } + if (policy.deniedBlockTypes) { + Object.freeze(policy.deniedBlockTypes); + } + return Object.freeze(policy); +} + +export function freezeAutocompleteControllerState( + state: AutocompleteControllerState, +): AutocompleteControllerState { + Object.freeze(state.settings); + freezeBlockPolicy(state.blockPolicy); + Object.freeze(state.metrics); + for (const timing of state.providerTimings) { + Object.freeze(timing); + } + Object.freeze(state.providerTimings); + Object.freeze(state.diagnostics); + return Object.freeze(state); +} + +export function freezeProviderDescriptors( + descriptors: readonly AutocompleteProviderDescriptor[], +): readonly AutocompleteProviderDescriptor[] { + for (const descriptor of descriptors) { + Object.freeze(descriptor); + } + return Object.freeze([...descriptors]); +} + +export function freezeAutocompleteControllerSnapshot( + snapshot: AutocompleteControllerSnapshot, +): AutocompleteControllerSnapshot { + return Object.freeze(snapshot); +} + +export function incrementPolicyInvalidationMetrics( + metrics: AutocompleteControllerState["metrics"], + stage: AutocompletePolicyInvalidationStage, +): AutocompleteControllerState["metrics"] { + return { + ...metrics, + policyInvalidationScheduledCount: + metrics.policyInvalidationScheduledCount + + (stage === "scheduled" ? 1 : 0), + policyInvalidationRequestingCount: + metrics.policyInvalidationRequestingCount + + (stage === "requesting" ? 1 : 0), + policyInvalidationShowingCount: + metrics.policyInvalidationShowingCount + + (stage === "showing" ? 1 : 0), + }; +} diff --git a/packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts b/packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts new file mode 100644 index 0000000..1e513fd --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteControllerState.ts @@ -0,0 +1,238 @@ +import type { Editor, FieldEditor, ModelAdapter } from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import type { AutocompleteProviderRegistry } from "./providers/registry"; +import type { AutocompleteContextProvider, AutocompleteProviderDescriptor } from "./providers/types"; +import type { + AutocompleteAcceptanceStrategy, + AutocompleteBlockedReason, + AutocompleteBlockPolicy, + AutocompleteControllerSnapshot, + AutocompleteControllerState, + AutocompleteDismissReason, + AutocompleteExtensionConfig, + AutocompletePolicyInvalidationStage, + AutocompleteRequestContext, +} from "./types"; +import { + createAutocompleteStructuredCandidate, + materializeStructuredCandidateAcceptance, +} from "./structuredCandidate"; +import type { AutocompleteContinuationState } from "./continuationState"; +import { AutocompleteControllerImpl } from "./autocompleteControllerCore"; +import { handleModelEvent, head, normalizeCompletionText, tail } from "./autocompleteCompletionText"; +import { logAutocompleteEvent, previewAutocompleteTextForLog } from "./autocompleteDebug"; +import { + areBlockPoliciesEqual, + cloneAutocompleteControllerState, + freezeAutocompleteControllerSnapshot, + freezeAutocompleteControllerState, + freezeProviderDescriptors, + incrementPolicyInvalidationMetrics, +} from "./autocompleteControllerSnapshots"; + +const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; + +type AutocompleteControllerRuntime = { + [key: string]: any; + _editor: Editor; + _model: ModelAdapter | undefined; + _debounceMs: number; + _acceptanceStrategy: AutocompleteAcceptanceStrategy; + _staleAfterMs: number; + _maxPrefixChars: number; + _maxSuffixChars: number; + _maxNeighborChars: number; + _maxProviderChars: number; + _maxProviderTimeMs: number; + _prefetchAfterAccept: boolean; + _providerRegistry: AutocompleteProviderRegistry; + _inlineCompletion: import("@pen/types").InlineCompletionController; + _listeners: Set<() => void>; + _snapshot: AutocompleteControllerSnapshot | null; + _providerDescriptorsSnapshot: readonly AutocompleteProviderDescriptor[] | null; + _state: AutocompleteControllerState; + _debounceTimer: ReturnType | null; + _abortController: AbortController | null; + _unsubscribeSelection: (() => void) | null; + _unsubscribeCommit: (() => void) | null; + _continuation: AutocompleteContinuationState; + _prefetchAbortController: AbortController | null; +}; + +type RuntimePrototype = Record; + +const ControllerPrototype = AutocompleteControllerImpl.prototype as unknown as RuntimePrototype; + +ControllerPrototype._setBlockedReason = function _setBlockedReason(this: AutocompleteControllerRuntime, reason: AutocompleteBlockedReason): void { + this._setState({ + diagnostics: { + ...this._state.diagnostics, + lastBlockedReason: reason, + }, + }); +} +; +ControllerPrototype._recordPolicyInvalidation = function _recordPolicyInvalidation(this: AutocompleteControllerRuntime, + policyFailure: AutocompleteBlockedReason, + invalidationStage: AutocompletePolicyInvalidationStage | null, +): void { + this._setBlockedReason(policyFailure); + if (invalidationStage) { + this._setState({ + metrics: incrementPolicyInvalidationMetrics( + this._state.metrics, + invalidationStage, + ), + diagnostics: { + ...this._state.diagnostics, + lastPolicyInvalidationStage: invalidationStage, + }, + }); + } + if (invalidationStage || this._continuation.hasPrefetchedContinuation) { + this.dismiss("policy-change"); + } +} +; +ControllerPrototype._invalidateForPolicyChange = function _invalidateForPolicyChange(this: AutocompleteControllerRuntime): void { + const activeBlockId = + this._continuation.sequence?.blockId ?? + this._getActiveSelectionBlockId(); + if (!activeBlockId) { + return; + } + const policyFailure = this._resolveCurrentBlockFailure(activeBlockId); + if (!policyFailure) { + return; + } + const invalidationStage = this._getPolicyInvalidationStage(); + this._recordPolicyInvalidation(policyFailure, invalidationStage); +} +; +ControllerPrototype._getActiveSelectionBlockId = function _getActiveSelectionBlockId(this: AutocompleteControllerRuntime): string | null { + const selection = this._editor.selection; + return selection?.type === "text" ? selection.focus.blockId : null; +} +; +ControllerPrototype._getPolicyInvalidationStage = function _getPolicyInvalidationStage(this: AutocompleteControllerRuntime): AutocompletePolicyInvalidationStage | null { + if ( + this._state.status === "scheduled" || + this._state.status === "requesting" + ) { + return this._state.status; + } + if ( + this._state.status === "showing" || + this._continuation.sequence || + this._continuation.hasPrefetchedContinuation + ) { + return "showing"; + } + return null; +} +; +ControllerPrototype._resolveCurrentBlockFailure = function _resolveCurrentBlockFailure(this: AutocompleteControllerRuntime, + blockId: string, +): AutocompleteBlockedReason | null { + const block = this._editor.getBlock(blockId); + if (!block) { + return "block-missing"; + } + return this._resolveContextEligibilityFailure(block.id, block.type); +} +; +ControllerPrototype._resolveContextEligibilityFailure = function _resolveContextEligibilityFailure(this: AutocompleteControllerRuntime, + blockId: string, + blockType: string | null, +): AutocompleteBlockedReason | null { + const blockPolicyFailure = this._resolveBlockPolicyFailure(blockType); + if (blockPolicyFailure) { + return blockPolicyFailure; + } + const fieldEditor = this._getFieldEditor() as + | (FieldEditor & { activeCellCoord?: { blockId: string } | null }) + | null; + if ( + fieldEditor?.activeCellCoord && + fieldEditor.activeCellCoord.blockId === blockId && + this._state.blockPolicy.allowInTables !== true + ) { + return "table-cell-active"; + } + return null; +} +; +ControllerPrototype._resolveBlockPolicyFailure = function _resolveBlockPolicyFailure(this: AutocompleteControllerRuntime, + blockType: string | null, +): AutocompleteBlockedReason | null { + if (!blockType) { + return null; + } + const allowedBlockTypes = this._state.blockPolicy.allowedBlockTypes; + if ( + allowedBlockTypes && + allowedBlockTypes.length > 0 && + !allowedBlockTypes.includes(blockType) + ) { + return "block-type-not-allowed"; + } + const deniedBlockTypes = this._state.blockPolicy.deniedBlockTypes; + if (deniedBlockTypes?.includes(blockType)) { + return "block-type-denied"; + } + if ( + blockType === "codeBlock" && + this._state.blockPolicy.allowInCodeBlocks === false + ) { + return "code-block-disabled"; + } + if ( + blockType === "table" && + this._state.blockPolicy.allowInTables !== true + ) { + return "table-disabled"; + } + return null; +} +; +ControllerPrototype._clearDebounceTimer = function _clearDebounceTimer(this: AutocompleteControllerRuntime): void { + if (this._debounceTimer !== null) { + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } +} +; +ControllerPrototype._setState = function _setState(this: AutocompleteControllerRuntime, next: Partial): void { + this._state = { + ...this._state, + ...next, + }; + this._invalidateSnapshot(); + this._emit(); +} +; +ControllerPrototype._getProviderDescriptorsSnapshot = function _getProviderDescriptorsSnapshot(this: AutocompleteControllerRuntime): readonly AutocompleteProviderDescriptor[] { + if (this._providerDescriptorsSnapshot === null) { + this._providerDescriptorsSnapshot = freezeProviderDescriptors( + this._providerRegistry.listProviderDescriptors(), + ); + } + return this._providerDescriptorsSnapshot; +} +; +ControllerPrototype._invalidateSnapshot = function _invalidateSnapshot(this: AutocompleteControllerRuntime): void { + this._snapshot = null; +} +; +ControllerPrototype._invalidateProviderDescriptorsSnapshot = function _invalidateProviderDescriptorsSnapshot(this: AutocompleteControllerRuntime): void { + this._providerDescriptorsSnapshot = null; + this._invalidateSnapshot(); +} +; +ControllerPrototype._emit = function _emit(this: AutocompleteControllerRuntime): void { + for (const listener of this._listeners) { + listener(); + } +} +; diff --git a/packages/extensions/ai-autocomplete/src/autocompleteDebug.ts b/packages/extensions/ai-autocomplete/src/autocompleteDebug.ts new file mode 100644 index 0000000..d80dd1f --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/autocompleteDebug.ts @@ -0,0 +1,26 @@ +const AI_AUTOCOMPLETE_LOG_PREFIX = "[ai-autocomplete]"; +const AUTOCOMPLETE_DEBUG_ENABLED = + typeof globalThis === "object" && + "process" in globalThis && + ( + globalThis as { + process?: { env?: Record }; + } + ).process?.env?.PEN_AUTOCOMPLETE_DEBUG === "true"; + +export function logAutocompleteEvent(message: string, details?: unknown): void { + if (!AUTOCOMPLETE_DEBUG_ENABLED) { + return; + } + if (details === undefined) { + console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`); + return; + } + console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`, details); +} + +export function previewAutocompleteTextForLog(text: string): string { + return JSON.stringify( + text.length > 160 ? `${text.slice(0, 160)}...` : text, + ); +} diff --git a/packages/extensions/ai-autocomplete/src/constants.ts b/packages/extensions/ai-autocomplete/src/constants.ts index 82a4a8d..68235c3 100644 --- a/packages/extensions/ai-autocomplete/src/constants.ts +++ b/packages/extensions/ai-autocomplete/src/constants.ts @@ -1,4 +1,4 @@ -export const DEFAULT_DEBOUNCE_MS = 80; +export const DEFAULT_DEBOUNCE_MS = 100; export const DEFAULT_MAX_PREFIX_CHARS = 400; export const DEFAULT_MAX_SUFFIX_CHARS = 200; export const DEFAULT_MAX_NEIGHBOR_CHARS = 160; diff --git a/packages/extensions/ai-autocomplete/src/continuationState.ts b/packages/extensions/ai-autocomplete/src/continuationState.ts new file mode 100644 index 0000000..38278dd --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/continuationState.ts @@ -0,0 +1,127 @@ +import type { OpOrigin, SelectionState } from "@pen/types"; +import { getOpOriginType } from "@pen/types"; +import type { AutocompleteStructuredCandidate } from "./structuredCandidate"; + +export type AutocompleteSequence = { + requestId: string; + blockId: string; + startOffset: number; + candidate: AutocompleteStructuredCandidate; + continuationDepth: number; +}; + +export type AcceptedContinuationTarget = { + sourceRequestId: string; + blockId: string; + startOffset: number; + continuationDepth: number; +}; + +export type PrefetchedContinuation = AcceptedContinuationTarget & { + requestId: string; + candidate: AutocompleteStructuredCandidate; +}; + +export class AutocompleteContinuationState { + private _sequence: AutocompleteSequence | null = null; + private _isAcceptingSequenceSegment = false; + private _prefetchedContinuation: PrefetchedContinuation | null = null; + private _pendingAcceptedContinuation: AcceptedContinuationTarget | null = + null; + + get sequence(): AutocompleteSequence | null { + return this._sequence; + } + + get hasPrefetchedContinuation(): boolean { + return this._prefetchedContinuation !== null; + } + + get hasPendingOrPrefetchedContinuation(): boolean { + return ( + this._pendingAcceptedContinuation !== null || + this._prefetchedContinuation !== null + ); + } + + setSequence(sequence: AutocompleteSequence): void { + this._sequence = sequence; + } + + clearSequence(): void { + this._sequence = null; + this._isAcceptingSequenceSegment = false; + } + + clearContinuations(): void { + this._prefetchedContinuation = null; + this._pendingAcceptedContinuation = null; + } + + reset(): void { + this.clearSequence(); + this.clearContinuations(); + } + + beginAcceptingSequenceSegment(): void { + this._isAcceptingSequenceSegment = true; + } + + consumeAcceptedAiCommit(origin: OpOrigin): boolean { + if ( + !this._isAcceptingSequenceSegment || + getOpOriginType(origin) !== "ai" + ) { + return false; + } + this._isAcceptingSequenceSegment = false; + return true; + } + + setPendingAcceptedContinuation( + target: AcceptedContinuationTarget, + ): void { + this._pendingAcceptedContinuation = target; + } + + setPrefetchedContinuation(prefetched: PrefetchedContinuation): void { + this._prefetchedContinuation = prefetched; + } + + activatePendingAcceptedContinuation( + selection: SelectionState, + ): AutocompleteSequence | null { + const prefetched = this._prefetchedContinuation; + const pending = this._pendingAcceptedContinuation; + if (!prefetched || !pending) { + return null; + } + if ( + prefetched.sourceRequestId !== pending.sourceRequestId || + prefetched.blockId !== pending.blockId || + prefetched.startOffset !== pending.startOffset + ) { + return null; + } + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock || + selection.focus.blockId !== pending.blockId || + selection.focus.offset !== pending.startOffset + ) { + return null; + } + + this._pendingAcceptedContinuation = null; + this._prefetchedContinuation = null; + this._sequence = { + requestId: prefetched.requestId, + blockId: prefetched.blockId, + startOffset: prefetched.startOffset, + candidate: prefetched.candidate, + continuationDepth: prefetched.continuationDepth, + }; + return this._sequence; + } +} diff --git a/packages/extensions/ai-autocomplete/src/extension.ts b/packages/extensions/ai-autocomplete/src/extension.ts index f921d1b..52a4632 100644 --- a/packages/extensions/ai-autocomplete/src/extension.ts +++ b/packages/extensions/ai-autocomplete/src/extension.ts @@ -1,1238 +1,11 @@ -import type { - Editor, - Extension, - FieldEditor, - InlineCompletionController, - ModelAdapter, - ModelStreamEvent, - TextSelection, -} from "@pen/types"; -import { - createDecorationSet, - ensureInlineCompletionController, -} from "@pen/core"; -import { - INLINE_COMPLETION_SLOT, - AI_AUTOCOMPLETE_CONTROLLER_SLOT, - FIELD_EDITOR_SLOT_KEY, - defineExtension, -} from "@pen/types"; -import { - DEFAULT_DEBOUNCE_MS, - DEFAULT_ACCEPTANCE_STRATEGY, - DEFAULT_MAX_NEIGHBOR_CHARS, - DEFAULT_MAX_PREFIX_CHARS, - DEFAULT_MAX_PROVIDER_CHARS, - DEFAULT_MAX_PROVIDER_TIME_MS, - DEFAULT_MAX_SUFFIX_CHARS, - DEFAULT_PREFETCH_AFTER_ACCEPT, - DEFAULT_STALE_AFTER_MS, -} from "./constants"; -import { buildAutocompleteMessages } from "./promptBuilder"; -import { builtinAutocompleteProviders } from "./providers/builtins"; -import { AutocompleteProviderRegistry } from "./providers/registry"; -import type { - AutocompleteContextProvider, - AutocompleteProviderDescriptor, -} from "./providers/types"; -import type { - AutocompleteAcceptanceStrategy, - AutocompleteBlockedReason, - AutocompleteBlockPolicy, - AutocompleteController, - AutocompleteControllerSnapshot, - AutocompleteControllerState, - AutocompleteDismissReason, - AutocompleteExtensionConfig, - AutocompletePolicyInvalidationStage, - AutocompleteRequestContext, -} from "./types"; -import { - createAutocompleteStructuredCandidate, - materializeStructuredCandidateAcceptance, - type AutocompleteStructuredCandidate, -} from "./structuredCandidate"; +import type { Editor, Extension, InlineCompletionController } from "@pen/types"; +import { createDecorationSet, ensureInlineCompletionController } from "@pen/core"; +import { AI_AUTOCOMPLETE_CONTROLLER_SLOT, defineExtension } from "@pen/types"; +import type { AutocompleteController, AutocompleteExtensionConfig } from "./types"; +import { AutocompleteControllerImpl } from "./autocompleteController"; export const AI_AUTOCOMPLETE_EXTENSION_NAME = "ai-autocomplete"; export const AUTOCOMPLETE_CONTROLLER_SLOT = AI_AUTOCOMPLETE_CONTROLLER_SLOT; -const AI_AUTOCOMPLETE_LOG_PREFIX = "[ai-autocomplete]"; -const AUTOCOMPLETE_DEBUG_ENABLED = - typeof globalThis === "object" && - "process" in globalThis && - (globalThis as { - process?: { env?: Record }; - }).process?.env?.PEN_AUTOCOMPLETE_DEBUG === "true"; -const AUTOCOMPLETE_REQUEST_MODE = "inline-autocomplete"; -const PROSE_BLOCK_TYPES = new Set(["paragraph", "heading", "blockquote", "callout"]); -const MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS = 3; - -class AutocompleteControllerImpl implements AutocompleteController { - private readonly _editor: Editor; - private readonly _model: ModelAdapter | undefined; - private _debounceMs: number; - private _acceptanceStrategy: AutocompleteAcceptanceStrategy; - private _staleAfterMs: number; - private readonly _maxPrefixChars: number; - private readonly _maxSuffixChars: number; - private readonly _maxNeighborChars: number; - private readonly _maxProviderChars: number; - private readonly _maxProviderTimeMs: number; - private _prefetchAfterAccept: boolean; - private readonly _providerRegistry: AutocompleteProviderRegistry; - private readonly _inlineCompletion: InlineCompletionController; - private readonly _listeners = new Set<() => void>(); - private _snapshot: AutocompleteControllerSnapshot | null = null; - private _providerDescriptorsSnapshot: - | readonly AutocompleteProviderDescriptor[] - | null = null; - private _state: AutocompleteControllerState = { - enabled: true, - status: "idle", - activeRequestId: null, - visibleSuggestionId: null, - settings: { - debounceMs: DEFAULT_DEBOUNCE_MS, - prefetchAfterAccept: DEFAULT_PREFETCH_AFTER_ACCEPT, - acceptanceStrategy: "full", - staleAfterMs: DEFAULT_STALE_AFTER_MS, - }, - blockPolicy: { - allowInCodeBlocks: true, - allowInTables: false, - deniedBlockTypes: ["database"], - }, - metrics: { - requestCount: 0, - successCount: 0, - cancelCount: 0, - staleDropCount: 0, - explicitTabTriggerCount: 0, - acceptCount: 0, - policyInvalidationScheduledCount: 0, - policyInvalidationRequestingCount: 0, - policyInvalidationShowingCount: 0, - }, - providerTimings: [], - diagnostics: { - lastDismissReason: null, - lastBlockedReason: null, - lastPolicyInvalidationStage: null, - }, - }; - private _debounceTimer: ReturnType | null = null; - private _abortController: AbortController | null = null; - private _unsubscribeSelection: (() => void) | null = null; - private _unsubscribeCommit: (() => void) | null = null; - private _sequence: { - requestId: string; - blockId: string; - startOffset: number; - candidate: AutocompleteStructuredCandidate; - continuationDepth: number; - } | null = null; - private _isAcceptingSequenceSegment = false; - private _prefetchAbortController: AbortController | null = null; - private _prefetchedContinuation: { - sourceRequestId: string; - requestId: string; - blockId: string; - startOffset: number; - candidate: AutocompleteStructuredCandidate; - continuationDepth: number; - } | null = null; - private _pendingAcceptedContinuation: { - sourceRequestId: string; - blockId: string; - startOffset: number; - continuationDepth: number; - } | null = null; - - constructor( - editor: Editor, - config: AutocompleteExtensionConfig, - services: { inlineCompletion: InlineCompletionController }, - ) { - this._editor = editor; - this._inlineCompletion = services.inlineCompletion; - this._model = config.model; - this._debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; - this._acceptanceStrategy = config.acceptanceStrategy ?? "full"; - this._staleAfterMs = config.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; - this._state.blockPolicy = { - allowInCodeBlocks: true, - allowInTables: false, - deniedBlockTypes: ["database"], - ...config.blockPolicy, - }; - this._maxPrefixChars = config.maxPrefixChars ?? DEFAULT_MAX_PREFIX_CHARS; - this._maxSuffixChars = config.maxSuffixChars ?? DEFAULT_MAX_SUFFIX_CHARS; - this._maxNeighborChars = config.maxNeighborChars ?? DEFAULT_MAX_NEIGHBOR_CHARS; - this._maxProviderChars = config.maxProviderChars ?? DEFAULT_MAX_PROVIDER_CHARS; - this._maxProviderTimeMs = - config.maxProviderTimeMs ?? DEFAULT_MAX_PROVIDER_TIME_MS; - this._prefetchAfterAccept = - config.prefetchAfterAccept ?? DEFAULT_PREFETCH_AFTER_ACCEPT; - this._providerRegistry = new AutocompleteProviderRegistry([ - ...builtinAutocompleteProviders, - ...(config.providers ?? []), - ]); - this._state.enabled = config.enabled ?? true; - this._state.settings = { - debounceMs: this._debounceMs, - prefetchAfterAccept: this._prefetchAfterAccept, - acceptanceStrategy: this._acceptanceStrategy, - staleAfterMs: this._staleAfterMs, - }; - - this._unsubscribeSelection = this._editor.onSelectionChange(() => { - if (this._shouldDismissForSelectionChange()) { - this.dismiss("selection-change"); - } - }); - this._unsubscribeCommit = this._editor.onDocumentCommit((event) => { - if (!this._state.enabled) { - return; - } - if (this._isAcceptingSequenceSegment && event.origin === "ai") { - this._isAcceptingSequenceSegment = false; - return; - } - if (event.origin !== "user" && event.origin !== "input-rule") { - if (this._shouldDismissForExternalCommit(event.affectedBlocks)) { - this.dismiss("external-edit"); - } - return; - } - this.request(); - }); - } - - destroy(): void { - this._unsubscribeSelection?.(); - this._unsubscribeSelection = null; - this._unsubscribeCommit?.(); - this._unsubscribeCommit = null; - this._clearDebounceTimer(); - this._abortController?.abort(); - this._abortController = null; - this._prefetchAbortController?.abort(); - this._prefetchAbortController = null; - this._pendingAcceptedContinuation = null; - } - - getSnapshot(): AutocompleteControllerSnapshot { - if (this._snapshot === null) { - const state = cloneAutocompleteControllerState(this._state); - this._snapshot = freezeAutocompleteControllerSnapshot({ - state: freezeAutocompleteControllerState(state), - providerDescriptors: this._getProviderDescriptorsSnapshot(), - }); - } - return this._snapshot; - } - - getState(): AutocompleteControllerState { - return this.getSnapshot().state; - } - - getBlockPolicy(): Readonly { - return this.getSnapshot().state.blockPolicy; - } - - subscribe(listener: () => void): () => void { - this._listeners.add(listener); - return () => this._listeners.delete(listener); - } - - setEnabled(enabled: boolean): void { - if (this._state.enabled === enabled) { - return; - } - this._state = { - ...this._state, - enabled, - status: enabled ? "idle" : "idle", - activeRequestId: null, - }; - this._invalidateSnapshot(); - if (!enabled) { - this.dismiss("disabled"); - } - this._emit(); - } - - request(options?: { explicit?: boolean }): boolean { - if (!this._state.enabled) { - this._setBlockedReason("disabled"); - return false; - } - if (!this._model) { - this._setBlockedReason("missing-model"); - return false; - } - // Validate that autocomplete is currently eligible, but defer reading the - // exact caret context until the debounced request actually runs. - if (!this._buildContext()) { - return false; - } - this.dismiss("request-replaced"); - const requestId = crypto.randomUUID(); - this._setState({ - status: "scheduled", - activeRequestId: requestId, - metrics: { - ...this._state.metrics, - requestCount: this._state.metrics.requestCount + 1, - explicitTabTriggerCount: - this._state.metrics.explicitTabTriggerCount + - (options?.explicit ? 1 : 0), - }, - diagnostics: { - ...this._state.diagnostics, - lastBlockedReason: null, - lastPolicyInvalidationStage: null, - }, - }); - this._clearDebounceTimer(); - const delay = options?.explicit ? 0 : this._debounceMs; - logAutocompleteEvent("request scheduled", { - requestId, - explicit: options?.explicit ?? false, - debounceMs: delay, - }); - this._debounceTimer = setTimeout(() => { - void this._runRequest(requestId); - }, delay); - return true; - } - - acceptVisibleSuggestion(): boolean { - if (!this._sequence || !this.hasVisibleSuggestion()) { - return false; - } - const policyFailure = this._resolveCurrentBlockFailure(this._sequence.blockId); - if (policyFailure) { - this._recordPolicyInvalidation(policyFailure, "showing"); - return false; - } - return this._acceptFullVisibleSuggestion({ - activateContinuation: true, - }); - } - - private _acceptFullVisibleSuggestion(options?: { - activateContinuation?: boolean; - }): boolean { - if (!this._sequence) { - return false; - } - const candidate = this._sequence.candidate; - if ( - candidate.inlineText.length === 0 && - candidate.previewBlocks.length === 0 - ) { - this.dismiss(); - return false; - } - const blockId = this._sequence.blockId; - const requestId = this._sequence.requestId; - const continuationDepth = this._sequence.continuationDepth + 1; - const acceptanceResult = materializeStructuredCandidateAcceptance({ - blockId, - offset: this._sequence.startOffset, - candidate, - }); - logAutocompleteEvent("accept visible suggestion", { - requestId, - blockId, - startOffset: this._sequence.startOffset, - inlineLength: candidate.inlineText.length, - inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), - appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map((block) => block.type), - opTypes: acceptanceResult.ops.map((op) => op.type), - nextCaretBlockId: acceptanceResult.selection.blockId, - nextCaretOffset: acceptanceResult.selection.offset, - }); - this._isAcceptingSequenceSegment = true; - this._editor.apply(acceptanceResult.ops, { origin: "ai", undoGroup: true }); - const acceptedBlock = this._editor.getBlock(blockId); - const firstNextBlock = acceptedBlock?.next ?? null; - const secondNextBlock = firstNextBlock?.next ?? null; - logAutocompleteEvent( - `accept applied summary requestId=${requestId} appendedBlockCount=${candidate.appendedBlocks.length} opTypes=${acceptanceResult.ops.map((op) => op.type).join(",")} currentBlockType=${acceptedBlock?.type ?? "missing"} currentBlockText=${previewAutocompleteTextForLog(acceptedBlock?.textContent() ?? "")} nextBlockType=${firstNextBlock?.type ?? "none"} nextBlockText=${previewAutocompleteTextForLog(firstNextBlock?.textContent() ?? "")} nextNextBlockType=${secondNextBlock?.type ?? "none"} nextNextBlockText=${previewAutocompleteTextForLog(secondNextBlock?.textContent() ?? "")}`, - ); - const nextCaretBlockId = acceptanceResult.selection.blockId; - const nextCaretOffset = acceptanceResult.selection.offset; - this._setState({ - metrics: { - ...this._state.metrics, - acceptCount: this._state.metrics.acceptCount + 1, - }, - }); - const fieldEditor = this._getFieldEditor(); - this._editor.selectText( - nextCaretBlockId, - nextCaretOffset, - nextCaretOffset, - ); - if (fieldEditor) { - if (typeof fieldEditor.activateTextSelection === "function") { - fieldEditor.activateTextSelection( - nextCaretBlockId, - nextCaretOffset, - nextCaretOffset, - ); - } else if (typeof fieldEditor.activate === "function") { - fieldEditor.activate(nextCaretBlockId); - } - if (typeof fieldEditor.focus === "function") { - fieldEditor.focus(); - } - } - - if (options?.activateContinuation && this._prefetchAfterAccept) { - this._pendingAcceptedContinuation = { - sourceRequestId: requestId, - blockId: nextCaretBlockId, - startOffset: nextCaretOffset, - continuationDepth, - }; - this._clearVisibleSuggestionAfterAccept(); - this._startPrefetchForAcceptedContinuation({ - sourceRequestId: requestId, - blockId: nextCaretBlockId, - startOffset: nextCaretOffset, - continuationDepth, - }); - } else { - this.dismiss("accept"); - } - return true; - } - - hasVisibleSuggestion(): boolean { - return this._sequence !== null && this._state.visibleSuggestionId !== null; - } - - registerProvider(provider: AutocompleteContextProvider): () => void { - const unregister = this._providerRegistry.registerProvider(provider); - this._invalidateProviderDescriptorsSnapshot(); - this._emit(); - return () => { - unregister(); - this._invalidateProviderDescriptorsSnapshot(); - this._emit(); - }; - } - - listProviderDescriptors() { - return this.getSnapshot().providerDescriptors; - } - - updateRuntimeSettings( - settings: Partial, - ): void { - const nextDebounceMs = settings.debounceMs; - const nextPrefetchAfterAccept = settings.prefetchAfterAccept; - const nextAcceptanceStrategy = settings.acceptanceStrategy; - let changed = false; - - if ( - typeof nextDebounceMs === "number" && - Number.isFinite(nextDebounceMs) && - nextDebounceMs >= 0 && - nextDebounceMs !== this._debounceMs - ) { - this._debounceMs = nextDebounceMs; - changed = true; - } - - if ( - typeof nextPrefetchAfterAccept === "boolean" && - nextPrefetchAfterAccept !== this._prefetchAfterAccept - ) { - this._prefetchAfterAccept = nextPrefetchAfterAccept; - if (!nextPrefetchAfterAccept) { - this._prefetchAbortController?.abort(); - this._prefetchAbortController = null; - this._prefetchedContinuation = null; - this._pendingAcceptedContinuation = null; - } - changed = true; - } - - if ( - nextAcceptanceStrategy === "full" && - nextAcceptanceStrategy !== this._acceptanceStrategy - ) { - this._acceptanceStrategy = nextAcceptanceStrategy; - changed = true; - } - - const nextStaleAfterMs = settings.staleAfterMs; - if ( - typeof nextStaleAfterMs === "number" && - Number.isFinite(nextStaleAfterMs) && - nextStaleAfterMs >= 0 && - nextStaleAfterMs !== this._staleAfterMs - ) { - this._staleAfterMs = nextStaleAfterMs; - changed = true; - } - - if (!changed) { - return; - } - - this._setState({ - settings: { - debounceMs: this._debounceMs, - prefetchAfterAccept: this._prefetchAfterAccept, - acceptanceStrategy: this._acceptanceStrategy, - staleAfterMs: this._staleAfterMs, - }, - }); - } - - updateBlockPolicy(policy: Partial): void { - const nextPolicy: AutocompleteBlockPolicy = { - ...this._state.blockPolicy, - ...policy, - }; - if (areBlockPoliciesEqual(this._state.blockPolicy, nextPolicy)) { - return; - } - this._setState({ - blockPolicy: nextPolicy, - }); - this._invalidateForPolicyChange(); - } - - dismiss(reason: AutocompleteDismissReason = "external-edit"): void { - this._clearDebounceTimer(); - const cancelledRequest = - this._state.status === "scheduled" || this._state.status === "requesting"; - this._abortController?.abort(); - this._abortController = null; - this._prefetchAbortController?.abort(); - this._prefetchAbortController = null; - this._clearSequence(); - this._prefetchedContinuation = null; - this._pendingAcceptedContinuation = null; - this._setState({ - status: "idle", - activeRequestId: null, - visibleSuggestionId: null, - metrics: { - ...this._state.metrics, - cancelCount: - this._state.metrics.cancelCount + (cancelledRequest ? 1 : 0), - }, - diagnostics: { - ...this._state.diagnostics, - lastDismissReason: reason, - }, - }); - this._inlineCompletion.dismissSuggestion(); - } - - private async _runRequest(requestId: string): Promise { - if (this._state.activeRequestId !== requestId || !this._model) { - logAutocompleteEvent("request skipped before start", { - requestId, - hasModel: !!this._model, - activeRequestId: this._state.activeRequestId, - }); - return; - } - this._abortController?.abort(); - const abortController = new AbortController(); - this._abortController = abortController; - const context = this._buildContext(); - if (!context) { - logAutocompleteEvent("request blocked before prompt build", { - requestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - this._setState({ - status: "idle", - activeRequestId: null, - }); - return; - } - this._setState({ - status: "requesting", - activeRequestId: requestId, - }); - logAutocompleteEvent("request started", { - requestId, - blockId: context.blockId, - offset: context.offset, - }); - - const { messages, providerTimings } = await buildAutocompleteMessages({ - context, - registry: this._providerRegistry, - maxProviderChars: this._maxProviderChars, - maxProviderTimeMs: this._maxProviderTimeMs, - continuationDepth: 0, - }); - if (!this._shouldContinueRequest(requestId, context)) { - logAutocompleteEvent("request cancelled after prompt build", { - requestId, - activeRequestId: this._state.activeRequestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - return; - } - this._setState({ providerTimings }); - logAutocompleteEvent("request prompt ready", { - requestId, - providerTimings, - promptLength: String(messages[1]?.content ?? "").length, - }); - const startedAt = Date.now(); - - let text = ""; - try { - logAutocompleteEvent("request model stream opening", { requestId }); - for await (const event of this._model.stream({ - messages, - tools: [], - signal: abortController.signal, - requestMode: AUTOCOMPLETE_REQUEST_MODE, - })) { - if (!this._shouldContinueRequest(requestId, context)) { - logAutocompleteEvent("request cancelled during stream", { - requestId, - activeRequestId: this._state.activeRequestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - abortController.abort(); - return; - } - logAutocompleteEvent("request model event", { - requestId, - type: event.type, - }); - if (!handleModelEvent(event, (delta) => { - text += delta; - })) { - break; - } - } - } catch { - logAutocompleteEvent("request stream threw", { - requestId, - aborted: abortController.signal.aborted, - }); - if (!abortController.signal.aborted) { - this._setState({ - status: "idle", - activeRequestId: null, - }); - } - return; - } - - if (!this._shouldContinueRequest(requestId, context)) { - logAutocompleteEvent("request cancelled after stream", { - requestId, - activeRequestId: this._state.activeRequestId, - lastBlockedReason: this._state.diagnostics.lastBlockedReason, - }); - return; - } - if (Date.now() - startedAt > this._staleAfterMs) { - logAutocompleteEvent("request dropped as stale", { - requestId, - elapsedMs: Date.now() - startedAt, - staleAfterMs: this._staleAfterMs, - }); - this._setState({ - status: "idle", - activeRequestId: null, - metrics: { - ...this._state.metrics, - staleDropCount: this._state.metrics.staleDropCount + 1, - }, - diagnostics: { - ...this._state.diagnostics, - lastDismissReason: "stale", - }, - }); - return; - } - const normalizedText = normalizeCompletionText(context, text); - logAutocompleteEvent("request normalized text", { - requestId, - blockType: context.blockType, - rawLength: text.length, - rawPreview: previewAutocompleteTextForLog(text), - normalizedLength: normalizedText.length, - normalizedPreview: previewAutocompleteTextForLog(normalizedText), - }); - if (!normalizedText) { - logAutocompleteEvent("request produced empty normalized text", { - requestId, - rawLength: text.length, - }); - this._setState({ - status: "idle", - activeRequestId: null, - }); - return; - } - - const candidate = createAutocompleteStructuredCandidate( - this._editor, - normalizedText, - { - activeBlockType: context.blockType, - continuationDepth: 0, - }, - ); - this._sequence = { - requestId, - blockId: context.blockId, - startOffset: context.offset, - candidate, - continuationDepth: 0, - }; - this._setState({ - metrics: { - ...this._state.metrics, - successCount: this._state.metrics.successCount + 1, - }, - }); - logAutocompleteEvent("request produced suggestion", { - requestId, - blockType: context.blockType, - normalizedLength: normalizedText.length, - inlineLength: candidate.inlineText.length, - inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), - appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map((block) => block.type), - previewBlockCount: candidate.previewBlocks.length, - }); - this._showSequenceSuggestion(); - } - - private _buildContext(): AutocompleteRequestContext | null { - const selection = this._editor.selection; - if (selection == null) { - this._setBlockedReason("missing-context"); - return null; - } - if (selection.type !== "text") { - this._setBlockedReason("selection-not-text"); - return null; - } - if (!selection.isCollapsed) { - this._setBlockedReason("selection-not-collapsed"); - return null; - } - if (selection.isMultiBlock) { - this._setBlockedReason("selection-multi-block"); - return null; - } - const fieldEditor = this._getFieldEditor(); - if (!fieldEditor) { - this._setBlockedReason("field-editor-unavailable"); - return null; - } - if (!fieldEditor.isEditing) { - this._setBlockedReason("field-editor-not-editing"); - return null; - } - if (!fieldEditor.isFocused) { - this._setBlockedReason("field-editor-not-focused"); - return null; - } - if (fieldEditor.isComposing) { - this._setBlockedReason("field-editor-composing"); - return null; - } - return this._buildContextForPosition( - selection.focus.blockId, - selection.focus.offset, - ); - } - - private _buildContextForPosition( - blockId: string, - offset: number, - ): AutocompleteRequestContext | null { - const block = this._editor.getBlock(blockId); - if (!block) { - this._setBlockedReason("block-missing"); - return null; - } - const blockPolicyFailure = this._resolveContextEligibilityFailure( - block.id, - block.type, - ); - if (blockPolicyFailure) { - this._setBlockedReason(blockPolicyFailure); - return null; - } - const blockText = block.textContent(); - return { - editor: this._editor, - blockId: block.id, - blockType: block.type, - offset, - prefixText: tail(blockText.slice(0, offset), this._maxPrefixChars), - suffixText: head(blockText.slice(offset), this._maxSuffixChars), - previousBlockText: tail(block.prev?.textContent() ?? "", this._maxNeighborChars), - nextBlockText: head(block.next?.textContent() ?? "", this._maxNeighborChars), - }; - } - - private _shouldContinueRequest( - requestId: string, - context: AutocompleteRequestContext, - ): boolean { - if (this._state.activeRequestId !== requestId) { - logAutocompleteEvent("request continuation blocked: replaced", { - requestId, - activeRequestId: this._state.activeRequestId, - }); - return false; - } - const selection = this._editor.selection; - if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock || - selection.focus.blockId !== context.blockId || - selection.focus.offset !== context.offset - ) { - logAutocompleteEvent("request continuation blocked: selection changed", { - requestId, - expected: { - blockId: context.blockId, - offset: context.offset, - }, - actual: - selection?.type === "text" - ? { - type: selection.type, - blockId: selection.focus.blockId, - offset: selection.focus.offset, - isCollapsed: selection.isCollapsed, - isMultiBlock: selection.isMultiBlock, - } - : selection, - }); - return false; - } - const fieldEditor = this._getFieldEditor(); - if (!fieldEditor?.isEditing || !fieldEditor.isFocused || fieldEditor.isComposing) { - logAutocompleteEvent("request continuation blocked: field editor state", { - requestId, - fieldEditor: fieldEditor - ? { - isEditing: fieldEditor.isEditing, - isFocused: fieldEditor.isFocused, - isComposing: fieldEditor.isComposing, - focusBlockId: fieldEditor.focusBlockId, - } - : null, - }); - return false; - } - const block = this._editor.getBlock(context.blockId); - const policyFailure = block - ? this._resolveContextEligibilityFailure(block.id, block.type) - : "block-missing"; - if (policyFailure) { - this._setBlockedReason(policyFailure); - return false; - } - return true; - } - - private _shouldDismissForExternalCommit(affectedBlocks: readonly string[]): boolean { - const visibleSuggestion = this._inlineCompletion.getState().visibleSuggestion; - return !!visibleSuggestion && affectedBlocks.includes(visibleSuggestion.blockId); - } - - private _shouldDismissForSelectionChange(): boolean { - const visibleSuggestion = this._inlineCompletion.getState().visibleSuggestion; - if (!visibleSuggestion || visibleSuggestion.type !== "inline") { - return false; - } - const selection = this._editor.selection; - if (selection?.type !== "text" || !selection.isCollapsed || selection.isMultiBlock) { - return true; - } - return ( - selection.focus.blockId !== visibleSuggestion.blockId || - selection.focus.offset !== visibleSuggestion.offset - ); - } - - private _getFieldEditor(): FieldEditor | null { - return this._editor.internals.getSlot(FIELD_EDITOR_SLOT_KEY) ?? null; - } - - private _showSequenceSuggestion(): void { - if (!this._sequence) { - return; - } - const suggestionId = this._sequence.requestId; - const preview = this._sequence.candidate; - this._inlineCompletion.showSuggestion({ - id: suggestionId, - blockId: this._sequence.blockId, - offset: this._sequence.startOffset, - text: preview.inlineText, - type: "inline", - previewBlocks: preview.previewBlocks, - }); - this._setState({ - status: "showing", - activeRequestId: this._sequence.requestId, - visibleSuggestionId: suggestionId, - }); - } - - private _startPrefetchForAcceptedContinuation(options: { - sourceRequestId: string; - blockId: string; - startOffset: number; - continuationDepth: number; - }): void { - if (!this._prefetchAfterAccept) { - return; - } - const context = this._buildContextForPosition( - options.blockId, - options.startOffset, - ); - if (!context) { - return; - } - this._prefetchAbortController?.abort(); - const abortController = new AbortController(); - this._prefetchAbortController = abortController; - void this._runPrefetchRequest({ - abortController, - context, - continuationDepth: options.continuationDepth, - sourceRequestId: options.sourceRequestId, - }); - } - - private async _runPrefetchRequest(options: { - abortController: AbortController; - context: AutocompleteRequestContext; - continuationDepth: number; - sourceRequestId: string; - }): Promise { - if (!this._model) { - return; - } - const { abortController, context, continuationDepth, sourceRequestId } = - options; - const requestId = crypto.randomUUID(); - const { messages } = await buildAutocompleteMessages({ - context, - registry: this._providerRegistry, - maxProviderChars: this._maxProviderChars, - maxProviderTimeMs: this._maxProviderTimeMs, - mode: "continuation", - continuationDepth, - }); - if (abortController.signal.aborted) { - return; - } - - let text = ""; - try { - for await (const event of this._model.stream({ - messages, - tools: [], - signal: abortController.signal, - requestMode: AUTOCOMPLETE_REQUEST_MODE, - })) { - if (abortController.signal.aborted) { - return; - } - if (!handleModelEvent(event, (delta) => { - text += delta; - })) { - break; - } - } - } catch { - return; - } - - if (abortController.signal.aborted) { - return; - } - const normalizedText = normalizeCompletionText(context, text); - if (!normalizedText) { - logAutocompleteEvent("prefetch produced empty normalized text", { - requestId, - sourceRequestId, - blockType: context.blockType, - rawLength: text.length, - rawPreview: previewAutocompleteTextForLog(text), - }); - return; - } - const candidate = createAutocompleteStructuredCandidate( - this._editor, - normalizedText, - { - activeBlockType: context.blockType, - continuationDepth, - }, - ); - logAutocompleteEvent("prefetch produced suggestion", { - requestId, - sourceRequestId, - blockType: context.blockType, - rawLength: text.length, - rawPreview: previewAutocompleteTextForLog(text), - normalizedLength: normalizedText.length, - normalizedPreview: previewAutocompleteTextForLog(normalizedText), - inlineLength: candidate.inlineText.length, - inlinePreview: previewAutocompleteTextForLog(candidate.inlineText), - appendedBlockCount: candidate.appendedBlocks.length, - appendedBlockTypes: candidate.appendedBlocks.map((block) => block.type), - previewBlockCount: candidate.previewBlocks.length, - }); - this._prefetchedContinuation = { - sourceRequestId, - requestId, - blockId: context.blockId, - startOffset: context.offset, - candidate, - continuationDepth, - }; - this._activatePendingAcceptedContinuation(); - } - - private _activatePendingAcceptedContinuation(): boolean { - const prefetched = this._prefetchedContinuation; - const pending = this._pendingAcceptedContinuation; - if (!prefetched || !pending) { - return false; - } - if ( - prefetched.sourceRequestId !== pending.sourceRequestId || - prefetched.blockId !== pending.blockId || - prefetched.startOffset !== pending.startOffset - ) { - return false; - } - const selection = this._editor.selection; - if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock || - selection.focus.blockId !== pending.blockId || - selection.focus.offset !== pending.startOffset - ) { - return false; - } - this._pendingAcceptedContinuation = null; - this._sequence = { - requestId: prefetched.requestId, - blockId: prefetched.blockId, - startOffset: prefetched.startOffset, - candidate: prefetched.candidate, - continuationDepth: prefetched.continuationDepth, - }; - this._prefetchedContinuation = null; - this._showSequenceSuggestion(); - return true; - } - - private _clearSequence(): void { - this._sequence = null; - this._isAcceptingSequenceSegment = false; - } - - private _clearVisibleSuggestionAfterAccept(): void { - this._clearSequence(); - this._setState({ - status: "idle", - activeRequestId: null, - visibleSuggestionId: null, - diagnostics: { - ...this._state.diagnostics, - lastDismissReason: "accept", - }, - }); - this._inlineCompletion.dismissSuggestion(); - } - - private _setBlockedReason(reason: AutocompleteBlockedReason): void { - this._setState({ - diagnostics: { - ...this._state.diagnostics, - lastBlockedReason: reason, - }, - }); - } - - private _recordPolicyInvalidation( - policyFailure: AutocompleteBlockedReason, - invalidationStage: AutocompletePolicyInvalidationStage | null, - ): void { - this._setBlockedReason(policyFailure); - if (invalidationStage) { - this._setState({ - metrics: incrementPolicyInvalidationMetrics( - this._state.metrics, - invalidationStage, - ), - diagnostics: { - ...this._state.diagnostics, - lastPolicyInvalidationStage: invalidationStage, - }, - }); - } - if (invalidationStage || this._prefetchedContinuation) { - this.dismiss("policy-change"); - } - } - - private _invalidateForPolicyChange(): void { - const activeBlockId = this._sequence?.blockId ?? this._getActiveSelectionBlockId(); - if (!activeBlockId) { - return; - } - const policyFailure = this._resolveCurrentBlockFailure(activeBlockId); - if (!policyFailure) { - return; - } - const invalidationStage = this._getPolicyInvalidationStage(); - this._recordPolicyInvalidation(policyFailure, invalidationStage); - } - - private _getActiveSelectionBlockId(): string | null { - const selection = this._editor.selection; - return selection?.type === "text" ? selection.focus.blockId : null; - } - - private _getPolicyInvalidationStage(): AutocompletePolicyInvalidationStage | null { - if (this._state.status === "scheduled" || this._state.status === "requesting") { - return this._state.status; - } - if (this._state.status === "showing" || this._sequence || this._prefetchedContinuation) { - return "showing"; - } - return null; - } - - private _resolveCurrentBlockFailure( - blockId: string, - ): AutocompleteBlockedReason | null { - const block = this._editor.getBlock(blockId); - if (!block) { - return "block-missing"; - } - return this._resolveContextEligibilityFailure(block.id, block.type); - } - - private _resolveContextEligibilityFailure( - blockId: string, - blockType: string | null, - ): AutocompleteBlockedReason | null { - const blockPolicyFailure = this._resolveBlockPolicyFailure(blockType); - if (blockPolicyFailure) { - return blockPolicyFailure; - } - const fieldEditor = this._getFieldEditor() as - | (FieldEditor & { activeCellCoord?: { blockId: string } | null }) - | null; - if ( - fieldEditor?.activeCellCoord && - fieldEditor.activeCellCoord.blockId === blockId && - this._state.blockPolicy.allowInTables !== true - ) { - return "table-cell-active"; - } - return null; - } - - private _resolveBlockPolicyFailure( - blockType: string | null, - ): AutocompleteBlockedReason | null { - if (!blockType) { - return null; - } - const allowedBlockTypes = this._state.blockPolicy.allowedBlockTypes; - if ( - allowedBlockTypes && - allowedBlockTypes.length > 0 && - !allowedBlockTypes.includes(blockType) - ) { - return "block-type-not-allowed"; - } - const deniedBlockTypes = this._state.blockPolicy.deniedBlockTypes; - if (deniedBlockTypes?.includes(blockType)) { - return "block-type-denied"; - } - if ( - blockType === "codeBlock" && - this._state.blockPolicy.allowInCodeBlocks === false - ) { - return "code-block-disabled"; - } - if (blockType === "table" && this._state.blockPolicy.allowInTables !== true) { - return "table-disabled"; - } - return null; - } - - private _clearDebounceTimer(): void { - if (this._debounceTimer !== null) { - clearTimeout(this._debounceTimer); - this._debounceTimer = null; - } - } - - private _setState(next: Partial): void { - this._state = { - ...this._state, - ...next, - }; - this._invalidateSnapshot(); - this._emit(); - } - - private _getProviderDescriptorsSnapshot(): readonly AutocompleteProviderDescriptor[] { - if (this._providerDescriptorsSnapshot === null) { - this._providerDescriptorsSnapshot = freezeProviderDescriptors( - this._providerRegistry.listProviderDescriptors(), - ); - } - return this._providerDescriptorsSnapshot; - } - - private _invalidateSnapshot(): void { - this._snapshot = null; - } - - private _invalidateProviderDescriptorsSnapshot(): void { - this._providerDescriptorsSnapshot = null; - this._invalidateSnapshot(); - } - - private _emit(): void { - for (const listener of this._listeners) { - listener(); - } - } -} export function autocompleteExtension( config: AutocompleteExtensionConfig = {}, @@ -1246,7 +19,8 @@ export function autocompleteExtension( name: AI_AUTOCOMPLETE_EXTENSION_NAME, activateClient: async ({ editor }) => { activeEditor = editor; - const inlineCompletionRegistration = ensureInlineCompletionController(editor); + const inlineCompletionRegistration = + ensureInlineCompletionController(editor); inlineCompletion = inlineCompletionRegistration.controller; releaseInlineCompletion = inlineCompletionRegistration.release; controller = new AutocompleteControllerImpl(editor, config, { @@ -1263,395 +37,19 @@ export function autocompleteExtension( releaseInlineCompletion = null; activeEditor = null; }, - decorations: () => createDecorationSet([ - ...(inlineCompletion?.buildDecorations() ?? []), - ]), + decorations: () => + createDecorationSet([ + ...(inlineCompletion?.buildDecorations() ?? []), + ]), }); } export function getAutocompleteController( editor: Editor, ): AutocompleteController | null { - return editor.internals.getSlot( - AUTOCOMPLETE_CONTROLLER_SLOT, - ) ?? null; -} - -function handleModelEvent( - event: ModelStreamEvent, - onTextDelta: (delta: string) => void, -): boolean { - if (event.type === "text-delta") { - onTextDelta(event.delta); - return true; - } - if (event.type === "done" || event.type === "error") { - return false; - } - return true; -} - -function normalizeCompletionText( - context: AutocompleteRequestContext, - text: string, -): string { - const normalized = text.replace(/\r/g, ""); - const withoutFence = normalized.replace(/^```[a-zA-Z0-9_-]*\n?/, "").replace(/```$/, ""); - const withoutWrappedQuotes = stripWrappedCompletionQuotes(context, withoutFence); - const trimmedLeading = - withoutWrappedQuotes.startsWith("\n\n") || - startsWithStructuredBlockContinuation(withoutWrappedQuotes) - ? withoutWrappedQuotes - : withoutWrappedQuotes.replace(/^\s*\n/, ""); - if (!trimmedLeading) { - return ""; - } - let candidate = trimmedLeading; - const suffixEcho = longestCommonPrefix(context.suffixText, trimmedLeading); - if (suffixEcho.length > 0) { - candidate = trimmedLeading.slice(suffixEcho.length); - } else if (context.suffixText.length === 0) { - const prefixEcho = longestSuffixPrefixOverlap( - context.prefixText, - trimmedLeading, - ); - if (prefixEcho.length > 0) { - candidate = trimmedLeading.slice(prefixEcho.length); - } - } - candidate = maybeInsertMissingBoundarySpace(context, candidate); - candidate = stripLeadingBoundaryPunctuationArtifacts(context, candidate); - candidate = collapseDuplicateBoundaryWhitespace(context, candidate); - candidate = maybeCapitalizeSentenceStart(context, candidate); - if (shouldRejectLowQualityCompletion(context, candidate)) { - return ""; - } - return candidate; -} - -function startsWithStructuredBlockContinuation(text: string): boolean { - return /^\s*\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test(text); -} - -function longestCommonPrefix(left: string, right: string): string { - const maxLength = Math.min(left.length, right.length); - let index = 0; - while (index < maxLength && left[index] === right[index]) { - index += 1; - } - return left.slice(0, index); -} - -function longestSuffixPrefixOverlap(left: string, right: string): string { - const maxLength = Math.min(left.length, right.length); - for (let length = maxLength; length > 0; length -= 1) { - const overlap = right.slice(0, length); - if (left.endsWith(overlap)) { - return overlap; - } - } - return ""; -} - -function maybeInsertMissingBoundarySpace( - context: AutocompleteRequestContext, - completion: string, -): string { - if ( - !completion || - context.suffixText.length > 0 || - !PROSE_BLOCK_TYPES.has(context.blockType ?? "") - ) { - return completion; - } - const lastPrefixChar = context.prefixText.slice(-1); - const firstCompletionChar = completion[0]; - if (!isWordLikeChar(lastPrefixChar) || !isWordLikeChar(firstCompletionChar)) { - return completion; - } - if (!hasLikelyWordBoundary(completion)) { - return completion; - } - return ` ${completion}`; -} - -function stripWrappedCompletionQuotes( - context: AutocompleteRequestContext, - completion: string, -): string { - if (!completion || context.suffixText.length > 0) { - return completion; - } - const trimmed = completion.trim(); - if (trimmed.length < 2 || isLikelyInsideOpenQuote(context.prefixText)) { - return completion; - } - const unwrapped = unwrapMatchingQuotes(trimmed); - if (unwrapped == null) { - return completion; - } - const leadingWhitespace = completion.match(/^\s*/)?.[0] ?? ""; - const trailingWhitespace = completion.match(/\s*$/)?.[0] ?? ""; - return `${leadingWhitespace}${unwrapped}${trailingWhitespace}`; -} - -function stripLeadingBoundaryPunctuationArtifacts( - context: AutocompleteRequestContext, - completion: string, -): string { - if ( - !completion || - context.suffixText.length > 0 || - !PROSE_BLOCK_TYPES.has(context.blockType ?? "") - ) { - return completion; - } - const prefixEndsWithWhitespace = /\s$/.test(context.prefixText); - const prefixEndsSentence = /[.!?]["')\]]*\s*$/.test(context.prefixText); - if (!prefixEndsWithWhitespace && !prefixEndsSentence) { - return completion; - } - if (prefixEndsWithWhitespace) { - return completion.replace(/^([ \t]*)([,.;:!?]+)(?=\s|["'A-Z])/u, "$1"); - } - if (prefixEndsSentence) { - return completion.replace(/^([ \t]*)([,;:]+)(?=\s|["'A-Z])/u, "$1"); - } - return completion; -} - -function collapseDuplicateBoundaryWhitespace( - context: AutocompleteRequestContext, - completion: string, -): string { - if (!completion || context.suffixText.length > 0) { - return completion; - } - if (!/\s$/.test(context.prefixText)) { - return completion; - } - return completion.replace(/^[ \t]+/u, ""); -} - -function maybeCapitalizeSentenceStart( - context: AutocompleteRequestContext, - completion: string, -): string { - if ( - !completion || - context.suffixText.length > 0 || - !PROSE_BLOCK_TYPES.has(context.blockType ?? "") || - !/[.!?]["')\]]*\s*$/.test(context.prefixText) - ) { - return completion; - } - return completion.replace( - /^(\s*["'([{“‘-]*)([a-z])/u, - (_, prefix: string, character: string) => `${prefix}${character.toUpperCase()}`, - ); -} - -function shouldRejectLowQualityCompletion( - context: AutocompleteRequestContext, - completion: string, -): boolean { - const trimmed = completion.trim(); - if (!trimmed) { - return true; - } - if ( - PROSE_BLOCK_TYPES.has(context.blockType ?? "") && - context.suffixText.length === 0 && - countWordLikeTokens(trimmed) === 1 && - trimmed.length < MIN_PROSE_SINGLE_WORD_COMPLETION_CHARS && - !/[.!?]$/.test(trimmed) - ) { - // Single-character or two-character prose guesses tend to feel like flicker. - // Allow short but still meaningful continuations such as "cat", "the", or "and". - return true; - } - return false; -} - -function countWordLikeTokens(value: string): number { - return value.match(/[A-Za-z0-9_'-]+/g)?.length ?? 0; -} - -function hasLikelyWordBoundary(value: string): boolean { - return /[\s.,!?;:]/.test(value.slice(1)); -} - -function isWordLikeChar(value: string): boolean { - return /[A-Za-z0-9]/.test(value); -} - -function unwrapMatchingQuotes(value: string): string | null { - const quotePairs: Array<[string, string]> = [ - ['"', '"'], - ["'", "'"], - ["“", "”"], - ["‘", "’"], - ]; - for (const [open, close] of quotePairs) { - if (value.startsWith(open) && value.endsWith(close)) { - const inner = value.slice(open.length, value.length - close.length).trim(); - return inner.length > 0 ? inner : null; - } - } - return null; -} - -function isLikelyInsideOpenQuote(value: string): boolean { - const asciiDoubleQuotes = value.match(/"/g)?.length ?? 0; - const asciiSingleQuotes = value.match(/'/g)?.length ?? 0; - const smartOpenQuotes = value.match(/“/g)?.length ?? 0; - const smartCloseQuotes = value.match(/”/g)?.length ?? 0; - const smartOpenSingles = value.match(/‘/g)?.length ?? 0; - const smartCloseSingles = value.match(/’/g)?.length ?? 0; - return ( - asciiDoubleQuotes % 2 === 1 || - asciiSingleQuotes % 2 === 1 || - smartOpenQuotes > smartCloseQuotes || - smartOpenSingles > smartCloseSingles - ); -} - -function head(value: string, maxChars: number): string { - return value.length <= maxChars ? value : value.slice(0, maxChars); -} - -function tail(value: string, maxChars: number): string { - return value.length <= maxChars ? value : value.slice(-maxChars); -} - -function areBlockPoliciesEqual( - left: AutocompleteBlockPolicy, - right: AutocompleteBlockPolicy, -): boolean { return ( - left.allowInCodeBlocks === right.allowInCodeBlocks && - left.allowInTables === right.allowInTables && - areStringArraysEqual(left.allowedBlockTypes, right.allowedBlockTypes) && - areStringArraysEqual(left.deniedBlockTypes, right.deniedBlockTypes) + editor.internals.getSlot( + AUTOCOMPLETE_CONTROLLER_SLOT, + ) ?? null ); } - -function areStringArraysEqual( - left: readonly string[] | undefined, - right: readonly string[] | undefined, -): boolean { - if (left === right) { - return true; - } - if (!left || !right || left.length !== right.length) { - return false; - } - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) { - return false; - } - } - return true; -} - -function cloneBlockPolicy( - policy: AutocompleteBlockPolicy, -): AutocompleteBlockPolicy { - return { - allowInCodeBlocks: policy.allowInCodeBlocks, - allowInTables: policy.allowInTables, - allowedBlockTypes: policy.allowedBlockTypes - ? [...policy.allowedBlockTypes] - : undefined, - deniedBlockTypes: policy.deniedBlockTypes - ? [...policy.deniedBlockTypes] - : undefined, - }; -} - -function cloneAutocompleteControllerState( - state: AutocompleteControllerState, -): AutocompleteControllerState { - return { - enabled: state.enabled, - status: state.status, - activeRequestId: state.activeRequestId, - visibleSuggestionId: state.visibleSuggestionId, - settings: { ...state.settings }, - blockPolicy: cloneBlockPolicy(state.blockPolicy), - metrics: { ...state.metrics }, - providerTimings: state.providerTimings.map((timing) => ({ ...timing })), - diagnostics: { ...state.diagnostics }, - }; -} - -function freezeBlockPolicy( - policy: AutocompleteBlockPolicy, -): AutocompleteBlockPolicy { - if (policy.allowedBlockTypes) { - Object.freeze(policy.allowedBlockTypes); - } - if (policy.deniedBlockTypes) { - Object.freeze(policy.deniedBlockTypes); - } - return Object.freeze(policy); -} - -function freezeAutocompleteControllerState( - state: AutocompleteControllerState, -): AutocompleteControllerState { - Object.freeze(state.settings); - freezeBlockPolicy(state.blockPolicy); - Object.freeze(state.metrics); - for (const timing of state.providerTimings) { - Object.freeze(timing); - } - Object.freeze(state.providerTimings); - Object.freeze(state.diagnostics); - return Object.freeze(state); -} - -function freezeProviderDescriptors( - descriptors: readonly AutocompleteProviderDescriptor[], -): readonly AutocompleteProviderDescriptor[] { - for (const descriptor of descriptors) { - Object.freeze(descriptor); - } - return Object.freeze([...descriptors]); -} - -function freezeAutocompleteControllerSnapshot( - snapshot: AutocompleteControllerSnapshot, -): AutocompleteControllerSnapshot { - return Object.freeze(snapshot); -} - -function incrementPolicyInvalidationMetrics( - metrics: AutocompleteControllerState["metrics"], - stage: AutocompletePolicyInvalidationStage, -): AutocompleteControllerState["metrics"] { - return { - ...metrics, - policyInvalidationScheduledCount: - metrics.policyInvalidationScheduledCount + (stage === "scheduled" ? 1 : 0), - policyInvalidationRequestingCount: - metrics.policyInvalidationRequestingCount + (stage === "requesting" ? 1 : 0), - policyInvalidationShowingCount: - metrics.policyInvalidationShowingCount + (stage === "showing" ? 1 : 0), - }; -} - -function logAutocompleteEvent(message: string, details?: unknown): void { - if (!AUTOCOMPLETE_DEBUG_ENABLED) { - return; - } - if (details === undefined) { - console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`); - return; - } - console.log(`${AI_AUTOCOMPLETE_LOG_PREFIX} ${message}`, details); -} - -function previewAutocompleteTextForLog(text: string): string { - return JSON.stringify(text.length > 160 ? `${text.slice(0, 160)}...` : text); -} diff --git a/packages/extensions/ai-autocomplete/src/promptBuilder.test.ts b/packages/extensions/ai-autocomplete/src/promptBuilder.test.ts new file mode 100644 index 0000000..8920c15 --- /dev/null +++ b/packages/extensions/ai-autocomplete/src/promptBuilder.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { buildAutocompleteMessages } from "./promptBuilder"; +import { builtinAutocompleteProviders } from "./providers/builtins"; +import { AutocompleteProviderRegistry } from "./providers/registry"; + +describe("buildAutocompleteMessages", () => { + it("includes neighboring block text for local draft context", async () => { + const { messages } = await buildAutocompleteMessages({ + context: { + editor: {} as never, + blockId: "block-1", + blockType: "paragraph", + offset: 9, + prefixText: "Hey Jason", + suffixText: "", + previousBlockText: "Earlier accepted reply text.", + nextBlockText: "Original quoted message text.", + }, + registry: new AutocompleteProviderRegistry(builtinAutocompleteProviders), + maxProviderChars: 500, + maxProviderTimeMs: 10, + }); + + const userMessage = String(messages[1]?.content ?? ""); + + expect(userMessage).toContain( + 'previous_block="Earlier accepted reply text."', + ); + expect(userMessage).toContain( + 'next_block="Original quoted message text."', + ); + expect(userMessage.match(/previous_block=/g)).toHaveLength(1); + expect(userMessage.match(/next_block=/g)).toHaveLength(1); + }); +}); diff --git a/packages/extensions/ai-autocomplete/src/promptBuilder.ts b/packages/extensions/ai-autocomplete/src/promptBuilder.ts index a6cdb62..e91d18f 100644 --- a/packages/extensions/ai-autocomplete/src/promptBuilder.ts +++ b/packages/extensions/ai-autocomplete/src/promptBuilder.ts @@ -153,6 +153,12 @@ function buildPrompt( "cursor_here=true", `suffix=${JSON.stringify(context.suffixText)}`, ]; + if (context.previousBlockText.trim().length > 0) { + sections.push(`previous_block=${JSON.stringify(context.previousBlockText)}`); + } + if (context.nextBlockText.trim().length > 0) { + sections.push(`next_block=${JSON.stringify(context.nextBlockText)}`); + } if (mode === "continuation") { sections.push( diff --git a/packages/extensions/ai-autocomplete/src/providers/builtins.ts b/packages/extensions/ai-autocomplete/src/providers/builtins.ts index 178029e..65f7c2c 100644 --- a/packages/extensions/ai-autocomplete/src/providers/builtins.ts +++ b/packages/extensions/ai-autocomplete/src/providers/builtins.ts @@ -14,23 +14,4 @@ export const builtinAutocompleteProviders: readonly AutocompleteContextProvider[ }), provide: (ctx) => `block_type=${ctx.blockType ?? "unknown"}`, }), - createAutocompleteProvider({ - id: "neighbor-blocks", - priority: 50, - describe: () => ({ - id: "neighbor-blocks", - description: "Adds nearby block text around the cursor block", - kind: "local", - }), - provide: (ctx) => { - const sections: string[] = []; - if (ctx.previousBlockText) { - sections.push(`previous_block=${JSON.stringify(ctx.previousBlockText)}`); - } - if (ctx.nextBlockText) { - sections.push(`next_block=${JSON.stringify(ctx.nextBlockText)}`); - } - return sections.length > 0 ? sections.join("\n") : null; - }, - }), ]; diff --git a/packages/extensions/ai-autocomplete/src/structuredCandidate.ts b/packages/extensions/ai-autocomplete/src/structuredCandidate.ts index 952ce84..653e3f3 100644 --- a/packages/extensions/ai-autocomplete/src/structuredCandidate.ts +++ b/packages/extensions/ai-autocomplete/src/structuredCandidate.ts @@ -7,6 +7,7 @@ import { blocksToOps, normalizePendingBlocksForImport, parseMarkdownToBlocks, + splitPlainTextBlocks, type PendingBlock, } from "@pen/content-ops"; @@ -105,10 +106,22 @@ function parseStructuredSuggestion( blocks: PendingBlock[]; } | null { const normalizedText = text.replace(/\r/g, ""); + if ( + isProseBlockType(options?.activeBlockType) && + normalizedText.includes("\n") && + !containsStructuredBlockContinuation(normalizedText) + ) { + const proseStructuredSuggestion = parseProseLineStructuredSuggestion(normalizedText); + if (proseStructuredSuggestion) { + return proseStructuredSuggestion; + } + } + const splitIndex = findStructuredSuggestionBoundary(normalizedText); if (splitIndex >= 0) { const inlineText = normalizedText.slice(0, splitIndex); - const markdownTail = normalizedText.slice(splitIndex).replace(/^\n+/, ""); + const tail = normalizedText.slice(splitIndex); + const markdownTail = tail.replace(/^\n+/, ""); if (markdownTail.trim().length > 0) { const parsedBlocks = parseMarkdownToBlocks(markdownTail, editor); const normalizedBlocks = normalizePendingBlocksForImport( @@ -122,6 +135,15 @@ function parseStructuredSuggestion( blocks: normalizedBlocks, }; } + } else if (/^\n{2,}/.test(tail)) { + return { + inlineText, + blocks: [{ + type: "paragraph", + props: {}, + content: "", + }], + }; } } @@ -142,6 +164,19 @@ function parseStructuredSuggestion( return null; } +function containsStructuredBlockContinuation(text: string): boolean { + if (/\n(?=(?:#{1,6}\s|>\s|[+*]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.test(text)) { + return true; + } + + const dashLineMatches = text.match(/\n-\s+\S/g) ?? []; + if (dashLineMatches.length > 1) { + return true; + } + + return /^\n-\s+\S/.test(text); +} + function findStructuredSuggestionBoundary(text: string): number { const blankLineMatch = /\n{2,}/.exec(text); const markdownLineMatch = /\n(?=(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|\[[ xX]\]\s|```))/.exec( @@ -196,27 +231,70 @@ function parseProseLineStructuredSuggestion(text: string): { inlineText: string; blocks: PendingBlock[]; } | null { - const lines = text.split("\n"); - if (lines.length <= 1) { - return null; - } - const [inlineText, ...tailLines] = lines; - const paragraphLines = tailLines - .map((line) => line.trim()) - .filter((line) => line.length > 0); - if (paragraphLines.length === 0) { + const suggestion = splitAutocompleteProseBlocks(text); + if (!suggestion) { return null; } + return { - inlineText, - blocks: paragraphLines.map((line) => ({ + inlineText: suggestion.inlineText, + blocks: suggestion.blocks.map((content) => ({ type: "paragraph", props: {}, - content: line, + content, })), }; } +function splitAutocompleteProseBlocks(text: string): { + inlineText: string; + blocks: string[]; +} | null { + const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const leadingNewlineMatch = /^\n+/.exec(normalizedText); + if (leadingNewlineMatch) { + const tailBlocks = splitPlainTextBlocks(normalizedText.slice(leadingNewlineMatch[0].length)); + const leadingEmptyBlocks = createEmptyBlocks( + tailBlocks.length > 0 ? leadingNewlineMatch[0].length - 1 : leadingNewlineMatch[0].length, + ); + const blocks = [...leadingEmptyBlocks, ...tailBlocks]; + return blocks.length > 0 ? { inlineText: "", blocks } : null; + } + + const paragraphs = splitPlainTextBlocks(normalizedText); + const trailingEmptyBlocks = createTrailingEmptyBlocks(normalizedText); + if (paragraphs.length <= 1 && trailingEmptyBlocks.length === 0) { + return null; + } + + const [inlineParagraph, ...tailParagraphs] = paragraphs; + return { + inlineText: resolveAutocompleteInlineParagraphText(normalizedText, inlineParagraph ?? ""), + blocks: [...tailParagraphs, ...trailingEmptyBlocks], + }; +} + +function createTrailingEmptyBlocks(text: string): string[] { + const trailingNewlineMatch = /\n+$/.exec(text); + return createEmptyBlocks(trailingNewlineMatch?.[0].length ?? 0); +} + +function createEmptyBlocks(count: number): string[] { + return Array.from({ length: Math.max(0, count) }, () => ""); +} + +function resolveAutocompleteInlineParagraphText(text: string, fallback: string): string { + const firstNonEmptyLine = text + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .split("\n") + .find((line) => line.trim().length > 0); + + return firstNonEmptyLine?.trim() === fallback + ? firstNonEmptyLine.replace(/[ \t]+$/u, "") + : fallback; +} + function isProseBlockType(blockType: string | null | undefined): boolean { return ( blockType === "paragraph" || diff --git a/packages/extensions/ai-suggestions/package.json b/packages/extensions/ai-suggestions/package.json index 5eb7088..08a6e60 100644 --- a/packages/extensions/ai-suggestions/package.json +++ b/packages/extensions/ai-suggestions/package.json @@ -36,7 +36,7 @@ "README.md", "LICENSE.md" ], - "sideEffects": false, + "sideEffects": true, "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", diff --git a/packages/extensions/ai-suggestions/src/controller.ts b/packages/extensions/ai-suggestions/src/controller.ts index 03e81c6..5222d0d 100644 --- a/packages/extensions/ai-suggestions/src/controller.ts +++ b/packages/extensions/ai-suggestions/src/controller.ts @@ -1,771 +1,3 @@ -import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; -import type { DocumentCommitEvent, Editor, FieldEditor } from "@pen/types"; -import { buildApplySuggestionOps } from "./apply"; -import { - buildSuggestionFingerprint, - type CachedAnalysisResult, - isCacheEntryFresh, - isDismissFingerprintActive, -} from "./cache"; -import { - DEFAULT_CACHE_TTL_MS, - DEFAULT_DISMISS_MEMORY_MS, - DEFAULT_MAX_SUGGESTIONS_PER_SCOPE, - DEFAULT_MIN_CONFIDENCE, -} from "./constants"; -import { buildSuggestionGroups } from "./grouping"; -import { materializeSuggestionsFromCandidates } from "./matcher"; -import { analyzeSuggestionScope } from "./analyzer"; -import { AISuggestionScheduler } from "./scheduler"; -import { buildSuggestionScope } from "./scopeBuilder"; -import type { - AISuggestion, - AISuggestionCandidate, - AISuggestionGroup, - AISuggestionsController, - AISuggestionsExtensionConfig, - AISuggestionsMetrics, - AISuggestionsState, -} from "./types"; +import "./controllerRuntime"; -type StatePatch = Omit, "metrics"> & { - metrics?: Partial; -}; - -const INITIAL_METRICS: AISuggestionsMetrics = { - requestCount: 0, - successCount: 0, - errorCount: 0, - cancelCount: 0, - cacheHitCount: 0, - dismissedRepeatDropCount: 0, - suggestionShownCount: 0, - suggestionAppliedCount: 0, - suggestionDismissedCount: 0, - promptTokens: 0, - completionTokens: 0, -}; - -export class AISuggestionsControllerImpl implements AISuggestionsController { - private readonly editor: Editor; - private readonly config: AISuggestionsExtensionConfig; - private readonly listeners = new Set<() => void>(); - private readonly scheduler: AISuggestionScheduler; - private readonly analysisCache = new Map(); - private readonly dismissedFingerprints = new Map(); - private abortController: AbortController | null = null; - private state: AISuggestionsState; - - constructor(editor: Editor, config: AISuggestionsExtensionConfig = {}) { - this.editor = editor; - this.config = config; - this.state = { - enabled: config.enabled ?? true, - status: "idle", - activeRequestId: null, - activeSuggestionId: null, - activeSuggestionGroupId: null, - suggestions: [], - groups: [], - metrics: { ...INITIAL_METRICS }, - }; - this.scheduler = new AISuggestionScheduler(editor, config, { - onScheduledChange: (scheduled) => { - if (!this.state.enabled) { - return; - } - this.setState({ - status: scheduled ? "scheduled" : this.isRequesting() ? "requesting" : "idle", - }); - }, - }); - } - - getState(): AISuggestionsState { - return this.state; - } - - getSuggestionGroups(): readonly AISuggestionGroup[] { - return this.state.groups; - } - - getRuntimeSettings(): AISuggestionsExtensionConfig { - return { ...this.config }; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - updateRuntimeSettings( - patch: Partial< - Omit - >, - ): AISuggestionsExtensionConfig { - Object.assign(this.config, patch); - this.emit(false); - return this.getRuntimeSettings(); - } - - setEnabled(enabled: boolean): void { - if (this.state.enabled === enabled) { - return; - } - - if (!enabled) { - const wasRequesting = this.isRequesting(); - this.abortController?.abort(); - this.abortController = null; - this.scheduler.reset(); - this.replaceAllSuggestions([], { - enabled: false, - status: "idle", - activeRequestId: null, - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: wasRequesting - ? { - cancelCount: this.state.metrics.cancelCount + 1, - } - : undefined, - }); - return; - } - - this.setState({ - enabled: true, - status: this.scheduler.hasDirtyBlocks() - ? "scheduled" - : this.isRequesting() - ? "requesting" - : "idle", - }); - } - - setActiveSuggestion(id: string | null): void { - if (this.state.activeSuggestionId === id) { - return; - } - - const activeGroupId = - id == null - ? null - : this.state.groups.find((group) => group.suggestionIds.includes(id))?.id ?? - null; - - this.setState({ - activeSuggestionId: id, - activeSuggestionGroupId: activeGroupId, - }); - } - - setActiveSuggestionGroup(id: string | null): void { - if (this.state.activeSuggestionGroupId === id) { - return; - } - - const firstSuggestionId = - id == null - ? null - : this.state.groups.find((group) => group.id === id)?.suggestionIds[0] ?? null; - - this.setState({ - activeSuggestionGroupId: id, - activeSuggestionId: firstSuggestionId, - }); - } - - request(options?: { force?: boolean; blockId?: string | null }): boolean { - if ( - !this.state.enabled || - (!options?.force && !this.isEditorReadyForSuggestions()) - ) { - return false; - } - - const ready = options?.force - ? this.resolveForcedDirtyBlock(options.blockId) - : this.scheduler.consumeNextReadyBlock(); - if (!ready) { - if (!this.isRequesting()) { - this.setState({ - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - }); - } - return false; - } - - const builtScope = buildSuggestionScope(this.editor, ready.state, this.config); - if (!builtScope) { - this.setState({ - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - }); - return false; - } - - this.pruneMemory(); - const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; - const cached = this.analysisCache.get(builtScope.scope.hash); - if (cached && isCacheEntryFresh(cached, cacheTtlMs)) { - const cachedCandidates = this.filterCandidatesForDisplay( - builtScope.scope.hash, - cached.candidates, - ); - const suggestions = materializeSuggestionsFromCandidates({ - blockId: builtScope.scope.blockId, - scopeId: builtScope.scope.id, - scopeHash: builtScope.scope.hash, - scopeText: builtScope.scope.text, - scopeFrom: builtScope.scope.from, - candidates: cachedCandidates, - }); - - this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - activeSuggestionId: suggestions[0]?.id ?? null, - metrics: { - cacheHitCount: this.state.metrics.cacheHitCount + 1, - suggestionShownCount: - this.state.metrics.suggestionShownCount + suggestions.length, - }, - }); - return true; - } - - this.abortController?.abort(); - this.abortController = new AbortController(); - const requestId = crypto.randomUUID(); - - this.setState({ - status: "requesting", - activeRequestId: requestId, - metrics: { - ...this.state.metrics, - requestCount: this.state.metrics.requestCount + 1, - }, - }); - - void this.runAnalysis(requestId, builtScope, this.abortController.signal); - return true; - } - - applySuggestion(id: string): boolean { - const suggestion = this.state.suggestions.find( - (item) => item.id === id && !item.invalidated, - ); - if (!suggestion) { - return false; - } - - const ops = buildApplySuggestionOps(this.editor, suggestion); - if (ops.length === 0) { - return false; - } - - this.editor.apply(ops, { - origin: "ai", - undoGroup: true, - }); - - const nextSuggestions = this.state.suggestions.filter( - (item) => - item.blockId !== suggestion.blockId || - !rangesOverlap(item.from, item.to, suggestion.from, suggestion.to), - ); - - this.replaceAllSuggestions(nextSuggestions, { - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: { - suggestionAppliedCount: - this.state.metrics.suggestionAppliedCount + 1, - }, - }); - return true; - } - - applySuggestionGroup(id: string): number { - const group = this.state.groups.find((item) => item.id === id); - if (!group) { - return 0; - } - - const suggestions = group.suggestionIds - .map((suggestionId) => - this.state.suggestions.find((item) => item.id === suggestionId) ?? null, - ) - .filter(Boolean) - .sort((left, right) => (right?.from ?? 0) - (left?.from ?? 0)); - - let appliedCount = 0; - for (const suggestion of suggestions) { - if (suggestion && this.applySuggestion(suggestion.id)) { - appliedCount += 1; - } - } - return appliedCount; - } - - dismissSuggestion(id: string): boolean { - const suggestion = this.state.suggestions.find((item) => item.id === id); - if (!suggestion) { - return false; - } - - this.dismissedFingerprints.set( - buildSuggestionFingerprint(suggestion.scopeHash, { - kind: suggestion.kind, - originalText: suggestion.originalText, - replacementText: suggestion.replacementText, - }), - Date.now(), - ); - - this.replaceAllSuggestions( - this.state.suggestions.filter((item) => item.id !== id), - { - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: { - suggestionDismissedCount: - this.state.metrics.suggestionDismissedCount + 1, - }, - }, - ); - return true; - } - - dismissSuggestionGroup(id: string): number { - const group = this.state.groups.find((item) => item.id === id); - if (!group) { - return 0; - } - - let dismissedCount = 0; - for (const suggestionId of group.suggestionIds) { - if (this.dismissSuggestion(suggestionId)) { - dismissedCount += 1; - } - } - return dismissedCount; - } - - dismissAllInBlock(blockId: string): number { - const removedCount = this.state.suggestions.filter( - (suggestion) => suggestion.blockId === blockId, - ).length; - if (removedCount === 0) { - return 0; - } - - this.replaceAllSuggestions( - this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), - { - activeSuggestionId: null, - activeSuggestionGroupId: null, - metrics: { - suggestionDismissedCount: - this.state.metrics.suggestionDismissedCount + removedCount, - }, - }, - ); - return removedCount; - } - - clearInvalidSuggestions(): void { - const nextSuggestions = this.state.suggestions.filter( - (suggestion) => !suggestion.invalidated, - ); - if (nextSuggestions.length === this.state.suggestions.length) { - return; - } - - this.replaceAllSuggestions(nextSuggestions, { - activeSuggestionId: nextSuggestions.some( - (suggestion) => suggestion.id === this.state.activeSuggestionId, - ) - ? this.state.activeSuggestionId - : null, - activeSuggestionGroupId: nextSuggestions.some((suggestion) => - this.state.groups - .find((group) => group.id === this.state.activeSuggestionGroupId) - ?.suggestionIds.includes(suggestion.id), - ) - ? this.state.activeSuggestionGroupId - : null, - }); - } - - handleDocumentCommit(event: DocumentCommitEvent): void { - if (event.origin !== "user" && event.origin !== "input-rule") { - return; - } - - const affectedBlockIds = new Set(event.affectedBlocks); - let changed = false; - const nextSuggestions = this.state.suggestions.map((suggestion) => { - if (!affectedBlockIds.has(suggestion.blockId) || suggestion.invalidated) { - return suggestion; - } - changed = true; - return { - ...suggestion, - invalidated: true, - }; - }); - - if (changed) { - this.replaceAllSuggestions(nextSuggestions); - } - - this.scheduler.markDirty(event, () => { - void Promise.resolve().then(() => { - this.request(); - }); - }); - } - - destroy(): void { - this.abortController?.abort(); - this.abortController = null; - this.scheduler.destroy(); - this.analysisCache.clear(); - this.dismissedFingerprints.clear(); - this.listeners.clear(); - } - - private async runAnalysis( - requestId: string, - builtScope: import("./scopeBuilder").BuiltSuggestionScope, - signal: AbortSignal, - ): Promise { - try { - const result = await analyzeSuggestionScope({ - editor: this.editor, - scope: builtScope, - config: this.config, - signal, - }); - - if (signal.aborted || this.state.activeRequestId !== requestId) { - if (!signal.aborted) { - this.setState({ - metrics: { - ...this.state.metrics, - cancelCount: this.state.metrics.cancelCount + 1, - }, - }); - } - return; - } - - this.analysisCache.set(builtScope.scope.hash, { - scopeHash: builtScope.scope.hash, - candidates: result.candidates, - createdAt: Date.now(), - }); - - const filteredCandidates = this.filterCandidatesForDisplay( - builtScope.scope.hash, - result.candidates, - ); - const suggestions = materializeSuggestionsFromCandidates({ - blockId: builtScope.scope.blockId, - scopeId: builtScope.scope.id, - scopeHash: builtScope.scope.hash, - scopeText: builtScope.scope.text, - scopeFrom: builtScope.scope.from, - candidates: filteredCandidates, - }); - - this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - activeRequestId: null, - activeSuggestionId: suggestions[0]?.id ?? null, - activeSuggestionGroupId: null, - metrics: { - successCount: this.state.metrics.successCount + 1, - suggestionShownCount: - this.state.metrics.suggestionShownCount + suggestions.length, - promptTokens: - this.state.metrics.promptTokens + result.usage.promptTokens, - completionTokens: - this.state.metrics.completionTokens + result.usage.completionTokens, - }, - }); - } catch (error) { - if (!signal.aborted) { - this.setState({ - status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", - activeRequestId: null, - metrics: { - ...this.state.metrics, - errorCount: this.state.metrics.errorCount + 1, - }, - }); - } - } - } - - private filterCandidatesForDisplay( - scopeHash: string, - candidates: readonly AISuggestionCandidate[], - ): readonly AISuggestionCandidate[] { - const minConfidence = this.config.minConfidence ?? DEFAULT_MIN_CONFIDENCE; - const dismissMemoryMs = - this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; - - const nextCandidates: AISuggestionCandidate[] = []; - let dismissedRepeatDropCount = 0; - - for (const candidate of candidates) { - if ( - typeof candidate.confidence === "number" && - candidate.confidence < minConfidence - ) { - continue; - } - - const fingerprint = buildSuggestionFingerprint(scopeHash, candidate); - const dismissedAt = this.dismissedFingerprints.get(fingerprint); - if ( - typeof dismissedAt === "number" && - isDismissFingerprintActive(dismissedAt, dismissMemoryMs) - ) { - dismissedRepeatDropCount += 1; - continue; - } - - nextCandidates.push(candidate); - } - - nextCandidates.sort(compareCandidatesForDisplay); - - const maxSuggestionsPerScope = - this.config.maxSuggestionsPerScope ?? DEFAULT_MAX_SUGGESTIONS_PER_SCOPE; - const limitedCandidates = nextCandidates.slice(0, maxSuggestionsPerScope); - - if (dismissedRepeatDropCount > 0) { - this.setState({ - metrics: { - ...this.state.metrics, - dismissedRepeatDropCount: - this.state.metrics.dismissedRepeatDropCount + dismissedRepeatDropCount, - }, - }); - } - - return limitedCandidates; - } - - private replaceSuggestionsForBlock( - blockId: string, - nextSuggestions: readonly AISuggestion[], - patch?: StatePatch, - ): void { - this.replaceAllSuggestions( - [ - ...this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), - ...nextSuggestions, - ], - patch, - ); - } - - private replaceAllSuggestions( - nextSuggestions: readonly AISuggestion[], - patch?: StatePatch, - ): void { - const groups = buildSuggestionGroups(nextSuggestions, this.config); - const hasActiveSuggestionPatch = - patch != null && - Object.prototype.hasOwnProperty.call(patch, "activeSuggestionId"); - const hasActiveGroupPatch = - patch != null && - Object.prototype.hasOwnProperty.call(patch, "activeSuggestionGroupId"); - const nextActiveSuggestionId = hasActiveSuggestionPatch - ? (patch?.activeSuggestionId ?? null) - : this.state.activeSuggestionId; - const activeGroupId = - hasActiveGroupPatch - ? (patch?.activeSuggestionGroupId ?? null) - : groups.find((group) => - nextActiveSuggestionId != null - ? group.suggestionIds.includes(nextActiveSuggestionId) - : false, - )?.id ?? null; - - this.setState({ - ...patch, - suggestions: nextSuggestions, - groups, - activeSuggestionId: nextActiveSuggestionId, - activeSuggestionGroupId: activeGroupId, - }); - } - - private setState( - patch: StatePatch, - ): void { - const previousState = this.state; - const nextState = { - ...this.state, - ...patch, - metrics: patch.metrics - ? { - ...this.state.metrics, - ...patch.metrics, - } - : this.state.metrics, - }; - this.state = nextState; - this.emit( - previousState.suggestions !== nextState.suggestions || - previousState.groups !== nextState.groups || - previousState.activeSuggestionId !== nextState.activeSuggestionId, - ); - } - - private emit(shouldRefreshDecorations = true): void { - if (shouldRefreshDecorations) { - this.editor.requestDecorationUpdate(); - } - for (const listener of this.listeners) { - listener(); - } - } - - private pruneMemory(): void { - const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; - const dismissMemoryMs = - this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; - const now = Date.now(); - - for (const [scopeHash, entry] of this.analysisCache) { - if (!isCacheEntryFresh(entry, cacheTtlMs, now)) { - this.analysisCache.delete(scopeHash); - } - } - - for (const [fingerprint, dismissedAt] of this.dismissedFingerprints) { - if (!isDismissFingerprintActive(dismissedAt, dismissMemoryMs, now)) { - this.dismissedFingerprints.delete(fingerprint); - } - } - } - - private isEditorReadyForSuggestions(): boolean { - const fieldEditor = - this.editor.internals.getSlot(FIELD_EDITOR_SLOT_KEY) ?? null; - if (!fieldEditor) { - return true; - } - return fieldEditor.isFocused && fieldEditor.isEditing && !fieldEditor.isComposing; - } - - private isRequesting(): boolean { - return this.state.activeRequestId != null && this.state.status === "requesting"; - } - - private resolveForcedDirtyBlock( - blockId: string | null | undefined, - ): { blockId: string; state: import("./scheduler").DirtyBlockState } | null { - const targetBlockId = - blockId ?? resolveSelectedBlockId(this.editor) ?? this.editor.firstBlock()?.id ?? null; - if (!targetBlockId) { - return null; - } - - const block = this.editor.getBlock(targetBlockId); - if (!block) { - return null; - } - - const text = block.textContent({ resolved: true }); - return { - blockId: targetBlockId, - state: { - blockId: targetBlockId, - firstChangedAt: Date.now(), - lastChangedAt: Date.now(), - changeCount: 1, - changedCharsEstimate: Math.max(1, text.trim().length), - lastRevision: this.editor.getBlockRevision(targetBlockId), - lastChangedOffset: resolvePreferredOffset(this.editor, targetBlockId, text.length), - }, - }; - } -} - -function resolveSelectedBlockId(editor: Editor): string | null { - const selection = editor.selection; - if (!selection) { - return null; - } - if (selection.type === "text") { - return selection.focus.blockId; - } - if (selection.type === "cell") { - return selection.blockId; - } - if (selection.type === "block") { - return selection.blockIds[0] ?? null; - } - return null; -} - -function resolvePreferredOffset( - editor: Editor, - blockId: string, - textLength: number, -): number { - const selection = editor.selection; - if (selection?.type === "text" && selection.focus.blockId === blockId) { - return selection.focus.offset; - } - return textLength; -} - -function compareCandidatesForDisplay( - left: AISuggestionCandidate, - right: AISuggestionCandidate, -): number { - const leftConfidence = left.confidence ?? 0; - const rightConfidence = right.confidence ?? 0; - if (leftConfidence !== rightConfidence) { - return rightConfidence - leftConfidence; - } - - const leftPriority = resolveKindPriority(left.kind); - const rightPriority = resolveKindPriority(right.kind); - if (leftPriority !== rightPriority) { - return leftPriority - rightPriority; - } - - return left.originalText.length - right.originalText.length; -} - -function resolveKindPriority(kind: AISuggestionCandidate["kind"]): number { - switch (kind) { - case "spelling": - return 1; - case "grammar": - return 2; - case "clarity": - return 3; - case "rephrase": - return 4; - } -} - -function rangesOverlap( - leftFrom: number, - leftTo: number, - rightFrom: number, - rightTo: number, -): boolean { - return leftFrom < rightTo && rightFrom < leftTo; -} +export { AISuggestionsControllerImpl } from "./controllerCore"; diff --git a/packages/extensions/ai-suggestions/src/controllerCore.ts b/packages/extensions/ai-suggestions/src/controllerCore.ts new file mode 100644 index 0000000..0229216 --- /dev/null +++ b/packages/extensions/ai-suggestions/src/controllerCore.ts @@ -0,0 +1,472 @@ +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import type { DocumentCommitEvent, Editor, FieldEditor } from "@pen/types"; +import { buildApplySuggestionOps } from "./apply"; +import { + buildSuggestionFingerprint, + type CachedAnalysisResult, + isCacheEntryFresh, + isDismissFingerprintActive, +} from "./cache"; +import { + DEFAULT_CACHE_TTL_MS, + DEFAULT_DISMISS_MEMORY_MS, + DEFAULT_MAX_SUGGESTIONS_PER_SCOPE, + DEFAULT_MIN_CONFIDENCE, +} from "./constants"; +import { buildSuggestionGroups } from "./grouping"; +import { materializeSuggestionsFromCandidates } from "./matcher"; +import { analyzeSuggestionScope } from "./analyzer"; +import { AISuggestionScheduler } from "./scheduler"; +import { buildSuggestionScope } from "./scopeBuilder"; +import { rangesOverlap } from "./controllerUtils"; +import type { + AISuggestion, + AISuggestionCandidate, + AISuggestionGroup, + AISuggestionsController, + AISuggestionsExtensionConfig, + AISuggestionsMetrics, + AISuggestionsState, +} from "./types"; + +type StatePatch = Omit, "metrics"> & { + metrics?: Partial; +}; + +const INITIAL_METRICS: AISuggestionsMetrics = { + requestCount: 0, + successCount: 0, + errorCount: 0, + cancelCount: 0, + cacheHitCount: 0, + dismissedRepeatDropCount: 0, + suggestionShownCount: 0, + suggestionAppliedCount: 0, + suggestionDismissedCount: 0, + promptTokens: 0, + completionTokens: 0, +}; + +export interface AISuggestionsControllerImpl { + runAnalysis( + requestId: string, + builtScope: import("./scopeBuilder").BuiltSuggestionScope, + signal: AbortSignal, + ): Promise; + filterCandidatesForDisplay( + scopeHash: string, + candidates: readonly AISuggestionCandidate[], +): readonly AISuggestionCandidate[]; + replaceSuggestionsForBlock( + blockId: string, + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void; + replaceAllSuggestions( + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void; + setState( + patch: StatePatch, + ): void; + emit(shouldRefreshDecorations?: boolean): void; + pruneMemory(): void; + isEditorReadyForSuggestions(): boolean; + isRequesting(): boolean; + resolveForcedDirtyBlock( + blockId: string | null | undefined, + ): ReturnType; +} + +export class AISuggestionsControllerImpl implements AISuggestionsController { + private readonly editor: Editor; + private readonly config: AISuggestionsExtensionConfig; + private readonly listeners = new Set<() => void>(); + private readonly scheduler: AISuggestionScheduler; + private readonly analysisCache = new Map(); + private readonly dismissedFingerprints = new Map(); + private abortController: AbortController | null = null; + private state: AISuggestionsState; + + constructor(editor: Editor, config: AISuggestionsExtensionConfig = {}) { + this.editor = editor; + this.config = config; + this.state = { + enabled: config.enabled ?? true, + status: "idle", + activeRequestId: null, + activeSuggestionId: null, + activeSuggestionGroupId: null, + suggestions: [], + groups: [], + metrics: { ...INITIAL_METRICS }, + }; + this.scheduler = new AISuggestionScheduler(editor, config, { + onScheduledChange: (scheduled) => { + if (!this.state.enabled) { + return; + } + this.setState({ + status: scheduled ? "scheduled" : this.isRequesting() ? "requesting" : "idle", + }); + }, + }); + } + + getState(): AISuggestionsState { + return this.state; + } + + getSuggestionGroups(): readonly AISuggestionGroup[] { + return this.state.groups; + } + + getRuntimeSettings(): AISuggestionsExtensionConfig { + return { ...this.config }; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + updateRuntimeSettings( + patch: Partial< + Omit + >, + ): AISuggestionsExtensionConfig { + Object.assign(this.config, patch); + this.emit(false); + return this.getRuntimeSettings(); + } + + setEnabled(enabled: boolean): void { + if (this.state.enabled === enabled) { + return; + } + + if (!enabled) { + const wasRequesting = this.isRequesting(); + this.abortController?.abort(); + this.abortController = null; + this.scheduler.reset(); + this.replaceAllSuggestions([], { + enabled: false, + status: "idle", + activeRequestId: null, + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: wasRequesting + ? { + cancelCount: this.state.metrics.cancelCount + 1, + } + : undefined, + }); + return; + } + + this.setState({ + enabled: true, + status: this.scheduler.hasDirtyBlocks() + ? "scheduled" + : this.isRequesting() + ? "requesting" + : "idle", + }); + } + + setActiveSuggestion(id: string | null): void { + if (this.state.activeSuggestionId === id) { + return; + } + + const activeGroupId = + id == null + ? null + : this.state.groups.find((group) => group.suggestionIds.includes(id))?.id ?? + null; + + this.setState({ + activeSuggestionId: id, + activeSuggestionGroupId: activeGroupId, + }); + } + + setActiveSuggestionGroup(id: string | null): void { + if (this.state.activeSuggestionGroupId === id) { + return; + } + + const firstSuggestionId = + id == null + ? null + : this.state.groups.find((group) => group.id === id)?.suggestionIds[0] ?? null; + + this.setState({ + activeSuggestionGroupId: id, + activeSuggestionId: firstSuggestionId, + }); + } + + request(options?: { force?: boolean; blockId?: string | null }): boolean { + if ( + !this.state.enabled || + (!options?.force && !this.isEditorReadyForSuggestions()) + ) { + return false; + } + + const ready = options?.force + ? this.resolveForcedDirtyBlock(options.blockId) + : this.scheduler.consumeNextReadyBlock(); + if (!ready) { + if (!this.isRequesting()) { + this.setState({ + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + }); + } + return false; + } + + const builtScope = buildSuggestionScope(this.editor, ready.state, this.config); + if (!builtScope) { + this.setState({ + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + }); + return false; + } + + this.pruneMemory(); + const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + const cached = this.analysisCache.get(builtScope.scope.hash); + if (cached && isCacheEntryFresh(cached, cacheTtlMs)) { + const cachedCandidates = this.filterCandidatesForDisplay( + builtScope.scope.hash, + cached.candidates, + ); + const suggestions = materializeSuggestionsFromCandidates({ + blockId: builtScope.scope.blockId, + scopeId: builtScope.scope.id, + scopeHash: builtScope.scope.hash, + scopeText: builtScope.scope.text, + scopeFrom: builtScope.scope.from, + candidates: cachedCandidates, + }); + + this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + activeSuggestionId: suggestions[0]?.id ?? null, + metrics: { + cacheHitCount: this.state.metrics.cacheHitCount + 1, + suggestionShownCount: + this.state.metrics.suggestionShownCount + suggestions.length, + }, + }); + return true; + } + + this.abortController?.abort(); + this.abortController = new AbortController(); + const requestId = crypto.randomUUID(); + + this.setState({ + status: "requesting", + activeRequestId: requestId, + metrics: { + ...this.state.metrics, + requestCount: this.state.metrics.requestCount + 1, + }, + }); + + void this.runAnalysis(requestId, builtScope, this.abortController.signal); + return true; + } + + applySuggestion(id: string): boolean { + const suggestion = this.state.suggestions.find( + (item) => item.id === id && !item.invalidated, + ); + if (!suggestion) { + return false; + } + + const ops = buildApplySuggestionOps(this.editor, suggestion); + if (ops.length === 0) { + return false; + } + + this.editor.apply(ops, { + origin: "ai", + undoGroup: true, + }); + + const nextSuggestions = this.state.suggestions.filter( + (item) => + item.blockId !== suggestion.blockId || + !rangesOverlap(item.from, item.to, suggestion.from, suggestion.to), + ); + + this.replaceAllSuggestions(nextSuggestions, { + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: { + suggestionAppliedCount: + this.state.metrics.suggestionAppliedCount + 1, + }, + }); + return true; + } + + applySuggestionGroup(id: string): number { + const group = this.state.groups.find((item) => item.id === id); + if (!group) { + return 0; + } + + const suggestions = group.suggestionIds + .map((suggestionId) => + this.state.suggestions.find((item) => item.id === suggestionId) ?? null, + ) + .filter(Boolean) + .sort((left, right) => (right?.from ?? 0) - (left?.from ?? 0)); + + let appliedCount = 0; + for (const suggestion of suggestions) { + if (suggestion && this.applySuggestion(suggestion.id)) { + appliedCount += 1; + } + } + return appliedCount; + } + + dismissSuggestion(id: string): boolean { + const suggestion = this.state.suggestions.find((item) => item.id === id); + if (!suggestion) { + return false; + } + + this.dismissedFingerprints.set( + buildSuggestionFingerprint(suggestion.scopeHash, { + kind: suggestion.kind, + originalText: suggestion.originalText, + replacementText: suggestion.replacementText, + }), + Date.now(), + ); + + this.replaceAllSuggestions( + this.state.suggestions.filter((item) => item.id !== id), + { + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: { + suggestionDismissedCount: + this.state.metrics.suggestionDismissedCount + 1, + }, + }, + ); + return true; + } + + dismissSuggestionGroup(id: string): number { + const group = this.state.groups.find((item) => item.id === id); + if (!group) { + return 0; + } + + let dismissedCount = 0; + for (const suggestionId of group.suggestionIds) { + if (this.dismissSuggestion(suggestionId)) { + dismissedCount += 1; + } + } + return dismissedCount; + } + + dismissAllInBlock(blockId: string): number { + const removedCount = this.state.suggestions.filter( + (suggestion) => suggestion.blockId === blockId, + ).length; + if (removedCount === 0) { + return 0; + } + + this.replaceAllSuggestions( + this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), + { + activeSuggestionId: null, + activeSuggestionGroupId: null, + metrics: { + suggestionDismissedCount: + this.state.metrics.suggestionDismissedCount + removedCount, + }, + }, + ); + return removedCount; + } + + clearInvalidSuggestions(): void { + const nextSuggestions = this.state.suggestions.filter( + (suggestion) => !suggestion.invalidated, + ); + if (nextSuggestions.length === this.state.suggestions.length) { + return; + } + + this.replaceAllSuggestions(nextSuggestions, { + activeSuggestionId: nextSuggestions.some( + (suggestion) => suggestion.id === this.state.activeSuggestionId, + ) + ? this.state.activeSuggestionId + : null, + activeSuggestionGroupId: nextSuggestions.some((suggestion) => + this.state.groups + .find((group) => group.id === this.state.activeSuggestionGroupId) + ?.suggestionIds.includes(suggestion.id), + ) + ? this.state.activeSuggestionGroupId + : null, + }); + } + + handleDocumentCommit(event: DocumentCommitEvent): void { + if (event.origin !== "user" && event.origin !== "input-rule") { + return; + } + + const affectedBlockIds = new Set(event.affectedBlocks); + let changed = false; + const nextSuggestions = this.state.suggestions.map((suggestion) => { + if (!affectedBlockIds.has(suggestion.blockId) || suggestion.invalidated) { + return suggestion; + } + changed = true; + return { + ...suggestion, + invalidated: true, + }; + }); + + if (changed) { + this.replaceAllSuggestions(nextSuggestions); + } + + this.scheduler.markDirty(event, () => { + void Promise.resolve().then(() => { + this.request(); + }); + }); + } + + destroy(): void { + this.abortController?.abort(); + this.abortController = null; + this.scheduler.destroy(); + this.analysisCache.clear(); + this.dismissedFingerprints.clear(); + this.listeners.clear(); + } + +} diff --git a/packages/extensions/ai-suggestions/src/controllerRuntime.ts b/packages/extensions/ai-suggestions/src/controllerRuntime.ts new file mode 100644 index 0000000..60534a3 --- /dev/null +++ b/packages/extensions/ai-suggestions/src/controllerRuntime.ts @@ -0,0 +1,315 @@ +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import type { DocumentCommitEvent, Editor, FieldEditor } from "@pen/types"; +import type { CachedAnalysisResult } from "./cache"; +import { + buildSuggestionFingerprint, + isCacheEntryFresh, + isDismissFingerprintActive, +} from "./cache"; +import { + DEFAULT_CACHE_TTL_MS, + DEFAULT_DISMISS_MEMORY_MS, + DEFAULT_MAX_SUGGESTIONS_PER_SCOPE, + DEFAULT_MIN_CONFIDENCE, +} from "./constants"; +import { buildSuggestionGroups } from "./grouping"; +import { materializeSuggestionsFromCandidates } from "./matcher"; +import { analyzeSuggestionScope } from "./analyzer"; +import type { AISuggestionScheduler } from "./scheduler"; +import type { + AISuggestion, + AISuggestionCandidate, + AISuggestionsExtensionConfig, + AISuggestionsMetrics, + AISuggestionsState, +} from "./types"; +import { AISuggestionsControllerImpl } from "./controllerCore"; +import { + compareCandidatesForDisplay, + rangesOverlap, + resolvePreferredOffset, + resolveSelectedBlockId, +} from "./controllerUtils"; + +type StatePatch = Omit, "metrics"> & { + metrics?: Partial; +}; + +type AISuggestionsControllerRuntime = { + [key: string]: any; + editor: Editor; + config: AISuggestionsExtensionConfig; + listeners: Set<() => void>; + scheduler: AISuggestionScheduler; + analysisCache: Map; + dismissedFingerprints: Map; + abortController: AbortController | null; + state: AISuggestionsState; +}; + +type AISuggestionsControllerRuntimePrototype = Record; + +const ControllerPrototype = AISuggestionsControllerImpl.prototype as unknown as AISuggestionsControllerRuntimePrototype; + +ControllerPrototype.runAnalysis = async function runAnalysis(this: AISuggestionsControllerRuntime, + requestId: string, + builtScope: import("./scopeBuilder").BuiltSuggestionScope, + signal: AbortSignal, +): Promise { + try { + const result = await analyzeSuggestionScope({ + editor: this.editor, + scope: builtScope, + config: this.config, + signal, + }); + + if (signal.aborted || this.state.activeRequestId !== requestId) { + if (!signal.aborted) { + this.setState({ + metrics: { + ...this.state.metrics, + cancelCount: this.state.metrics.cancelCount + 1, + }, + }); + } + return; + } + + this.analysisCache.set(builtScope.scope.hash, { + scopeHash: builtScope.scope.hash, + candidates: result.candidates, + createdAt: Date.now(), + }); + + const filteredCandidates = this.filterCandidatesForDisplay( + builtScope.scope.hash, + result.candidates, + ); + const suggestions = materializeSuggestionsFromCandidates({ + blockId: builtScope.scope.blockId, + scopeId: builtScope.scope.id, + scopeHash: builtScope.scope.hash, + scopeText: builtScope.scope.text, + scopeFrom: builtScope.scope.from, + candidates: filteredCandidates, + }); + + this.replaceSuggestionsForBlock(builtScope.scope.blockId, suggestions, { + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + activeRequestId: null, + activeSuggestionId: suggestions[0]?.id ?? null, + activeSuggestionGroupId: null, + metrics: { + successCount: this.state.metrics.successCount + 1, + suggestionShownCount: + this.state.metrics.suggestionShownCount + suggestions.length, + promptTokens: + this.state.metrics.promptTokens + result.usage.promptTokens, + completionTokens: + this.state.metrics.completionTokens + result.usage.completionTokens, + }, + }); + } catch (error) { + if (!signal.aborted) { + this.setState({ + status: this.scheduler.hasDirtyBlocks() ? "scheduled" : "idle", + activeRequestId: null, + metrics: { + ...this.state.metrics, + errorCount: this.state.metrics.errorCount + 1, + }, + }); + } + } +} +; +ControllerPrototype.filterCandidatesForDisplay = function filterCandidatesForDisplay(this: AISuggestionsControllerRuntime, + scopeHash: string, + candidates: readonly AISuggestionCandidate[], +): readonly AISuggestionCandidate[] { + const minConfidence = this.config.minConfidence ?? DEFAULT_MIN_CONFIDENCE; + const dismissMemoryMs = + this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; + + const nextCandidates: AISuggestionCandidate[] = []; + let dismissedRepeatDropCount = 0; + + for (const candidate of candidates) { + if ( + typeof candidate.confidence === "number" && + candidate.confidence < minConfidence + ) { + continue; + } + + const fingerprint = buildSuggestionFingerprint(scopeHash, candidate); + const dismissedAt = this.dismissedFingerprints.get(fingerprint); + if ( + typeof dismissedAt === "number" && + isDismissFingerprintActive(dismissedAt, dismissMemoryMs) + ) { + dismissedRepeatDropCount += 1; + continue; + } + + nextCandidates.push(candidate); + } + + nextCandidates.sort(compareCandidatesForDisplay); + + const maxSuggestionsPerScope = + this.config.maxSuggestionsPerScope ?? DEFAULT_MAX_SUGGESTIONS_PER_SCOPE; + const limitedCandidates = nextCandidates.slice(0, maxSuggestionsPerScope); + + if (dismissedRepeatDropCount > 0) { + this.setState({ + metrics: { + ...this.state.metrics, + dismissedRepeatDropCount: + this.state.metrics.dismissedRepeatDropCount + dismissedRepeatDropCount, + }, + }); + } + + return limitedCandidates; +} +; +ControllerPrototype.replaceSuggestionsForBlock = function replaceSuggestionsForBlock(this: AISuggestionsControllerRuntime, + blockId: string, + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void { + this.replaceAllSuggestions( + [ + ...this.state.suggestions.filter((suggestion) => suggestion.blockId !== blockId), + ...nextSuggestions, + ], + patch, + ); +} +; +ControllerPrototype.replaceAllSuggestions = function replaceAllSuggestions(this: AISuggestionsControllerRuntime, + nextSuggestions: readonly AISuggestion[], + patch?: StatePatch, +): void { + const groups = buildSuggestionGroups(nextSuggestions, this.config); + const hasActiveSuggestionPatch = + patch != null && + Object.prototype.hasOwnProperty.call(patch, "activeSuggestionId"); + const hasActiveGroupPatch = + patch != null && + Object.prototype.hasOwnProperty.call(patch, "activeSuggestionGroupId"); + const nextActiveSuggestionId = hasActiveSuggestionPatch + ? (patch?.activeSuggestionId ?? null) + : this.state.activeSuggestionId; + const activeGroupId = + hasActiveGroupPatch + ? (patch?.activeSuggestionGroupId ?? null) + : groups.find((group) => + nextActiveSuggestionId != null + ? group.suggestionIds.includes(nextActiveSuggestionId) + : false, + )?.id ?? null; + + this.setState({ + ...patch, + suggestions: nextSuggestions, + groups, + activeSuggestionId: nextActiveSuggestionId, + activeSuggestionGroupId: activeGroupId, + }); +} +; +ControllerPrototype.setState = function setState(this: AISuggestionsControllerRuntime, + patch: StatePatch, +): void { + const previousState = this.state; + const nextState = { + ...this.state, + ...patch, + metrics: patch.metrics + ? { + ...this.state.metrics, + ...patch.metrics, + } + : this.state.metrics, + }; + this.state = nextState; + this.emit( + previousState.suggestions !== nextState.suggestions || + previousState.groups !== nextState.groups || + previousState.activeSuggestionId !== nextState.activeSuggestionId, + ); +} +; +ControllerPrototype.emit = function emit(this: AISuggestionsControllerRuntime, shouldRefreshDecorations = true): void { + if (shouldRefreshDecorations) { + this.editor.requestDecorationUpdate(); + } + for (const listener of this.listeners) { + listener(); + } +} +; +ControllerPrototype.pruneMemory = function pruneMemory(this: AISuggestionsControllerRuntime): void { + const cacheTtlMs = this.config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + const dismissMemoryMs = + this.config.dismissMemoryMs ?? DEFAULT_DISMISS_MEMORY_MS; + const now = Date.now(); + + for (const [scopeHash, entry] of this.analysisCache) { + if (!isCacheEntryFresh(entry, cacheTtlMs, now)) { + this.analysisCache.delete(scopeHash); + } + } + + for (const [fingerprint, dismissedAt] of this.dismissedFingerprints) { + if (!isDismissFingerprintActive(dismissedAt, dismissMemoryMs, now)) { + this.dismissedFingerprints.delete(fingerprint); + } + } +} +; +ControllerPrototype.isEditorReadyForSuggestions = function isEditorReadyForSuggestions(this: AISuggestionsControllerRuntime): boolean { + const fieldEditor = + this.editor.internals.getSlot(FIELD_EDITOR_SLOT_KEY) ?? null; + if (!fieldEditor) { + return true; + } + return fieldEditor.isFocused && fieldEditor.isEditing && !fieldEditor.isComposing; +} +; +ControllerPrototype.isRequesting = function isRequesting(this: AISuggestionsControllerRuntime): boolean { + return this.state.activeRequestId != null && this.state.status === "requesting"; +} +; +ControllerPrototype.resolveForcedDirtyBlock = function resolveForcedDirtyBlock(this: AISuggestionsControllerRuntime, + blockId: string | null | undefined, +): { blockId: string; state: import("./scheduler").DirtyBlockState } | null { + const targetBlockId = + blockId ?? resolveSelectedBlockId(this.editor) ?? this.editor.firstBlock()?.id ?? null; + if (!targetBlockId) { + return null; + } + + const block = this.editor.getBlock(targetBlockId); + if (!block) { + return null; + } + + const text = block.textContent({ resolved: true }); + return { + blockId: targetBlockId, + state: { + blockId: targetBlockId, + firstChangedAt: Date.now(), + lastChangedAt: Date.now(), + changeCount: 1, + changedCharsEstimate: Math.max(1, text.trim().length), + lastRevision: this.editor.getBlockRevision(targetBlockId), + lastChangedOffset: resolvePreferredOffset(this.editor, targetBlockId, text.length), + }, + }; +} +; diff --git a/packages/extensions/ai-suggestions/src/controllerUtils.ts b/packages/extensions/ai-suggestions/src/controllerUtils.ts new file mode 100644 index 0000000..6fd07be --- /dev/null +++ b/packages/extensions/ai-suggestions/src/controllerUtils.ts @@ -0,0 +1,72 @@ +import type { Editor, TextSelection } from "@pen/types"; +import type { AISuggestionCandidate } from "./types"; + +export function resolveSelectedBlockId(editor: Editor): string | null { + const selection = editor.selection; + if (!selection) { + return null; + } + if (selection.type === "text") { + return selection.focus.blockId; + } + if (selection.type === "cell") { + return selection.blockId; + } + if (selection.type === "block") { + return selection.blockIds[0] ?? null; + } + return null; +} + +export function resolvePreferredOffset( + editor: Editor, + blockId: string, + textLength: number, +): number { + const selection = editor.selection; + if (selection?.type === "text" && selection.focus.blockId === blockId) { + return selection.focus.offset; + } + return textLength; +} + +export function compareCandidatesForDisplay( + left: AISuggestionCandidate, + right: AISuggestionCandidate, +): number { + const leftConfidence = left.confidence ?? 0; + const rightConfidence = right.confidence ?? 0; + if (leftConfidence !== rightConfidence) { + return rightConfidence - leftConfidence; + } + + const leftPriority = resolveKindPriority(left.kind); + const rightPriority = resolveKindPriority(right.kind); + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + + return left.originalText.length - right.originalText.length; +} + +function resolveKindPriority(kind: AISuggestionCandidate["kind"]): number { + switch (kind) { + case "spelling": + return 1; + case "grammar": + return 2; + case "clarity": + return 3; + case "rephrase": + return 4; + } +} + +export function rangesOverlap( + leftFrom: number, + leftTo: number, + rightFrom: number, + rightTo: number, +): boolean { + return leftFrom < rightTo && rightFrom < leftTo; +} diff --git a/packages/extensions/ai/package.json b/packages/extensions/ai/package.json index 05337ac..7b49bda 100644 --- a/packages/extensions/ai/package.json +++ b/packages/extensions/ai/package.json @@ -44,6 +44,7 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { + "@pen/content-ops": "workspace:*", "@pen/core": "workspace:*", "@pen/document-ops": "workspace:*", "@pen/types": "workspace:*" diff --git a/packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts b/packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts new file mode 100644 index 0000000..2ef7f4d --- /dev/null +++ b/packages/extensions/ai/src/__tests__/applySuggestedAIOperations.test.ts @@ -0,0 +1,155 @@ +import { createEditor } from "@pen/core"; +import { describe, expect, it } from "vitest"; +import { + acceptAllSuggestions, + acceptSuggestion, + applySuggestedAIOperations, + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, + rejectSuggestion, +} from "../index"; + +describe("applySuggestedAIOperations", () => { + it("creates accept-compatible text insert suggestions with provenance", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + + const result = applySuggestedAIOperations(editor, { + operations: [ + { type: "insert-text", blockId, offset: 0, text: "Hello" }, + ], + requestId: "request-1", + sessionId: "session-1", + turnId: "turn-1", + generationId: "generation-1", + model: "test-model", + suggestionIds: ["suggestion-insert"], + createdAt: 1_762_000_000_000, + }); + + expect(result.suggestionIds).toEqual(["suggestion-insert"]); + expect(result.suggestions[0]).toMatchObject({ + kind: "text", + id: "suggestion-insert", + action: "insert", + authorType: "ai", + requestId: "request-1", + sessionId: "session-1", + turnId: "turn-1", + generationId: "generation-1", + model: "test-model", + }); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello", + ); + + expect(acceptSuggestion(editor, "suggestion-insert")).toBe(true); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello"); + }); + + it("creates accept-compatible text replace suggestions", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const result = applySuggestedAIOperations(editor, { + operations: [ + { + type: "replace-text", + blockId, + offset: 0, + length: 5, + text: "Hi", + }, + ], + requestId: "request-2", + sessionId: "session-2", + turnId: "turn-2", + generationId: "generation-2", + suggestionIds: ["suggestion-delete", "suggestion-insert"], + }); + + expect(result.suggestionIds).toEqual([ + "suggestion-delete", + "suggestion-insert", + ]); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(2); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hi", + ); + + acceptAllSuggestions(editor); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hi"); + }); + + it("creates reject-compatible text delete suggestions", () => { + const editor = createEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const result = applySuggestedAIOperations(editor, { + operations: [ + { type: "delete-text", blockId, offset: 0, length: 5 }, + ], + requestId: "request-3", + sessionId: "session-3", + turnId: "turn-3", + generationId: "generation-3", + suggestionIds: ["suggestion-delete"], + }); + + expect(result.suggestionIds).toEqual(["suggestion-delete"]); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "", + ); + + expect(rejectSuggestion(editor, "suggestion-delete")).toBe(true); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello"); + }); + + it("creates reject-compatible block suggestions", () => { + const editor = createEditor(); + + const result = applySuggestedAIOperations(editor, { + operations: [ + { + type: "insert-block", + blockId: "ai-block", + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + requestId: "request-4", + sessionId: "session-4", + turnId: "turn-4", + generationId: "generation-4", + suggestionIds: ["suggestion-block"], + }); + + expect(result.suggestionIds).toEqual(["suggestion-block"]); + expect( + readBlockSuggestionMeta(editor.getBlock("ai-block")), + ).toMatchObject({ + id: "suggestion-block", + action: "insert-block", + requestId: "request-4", + sessionId: "session-4", + turnId: "turn-4", + generationId: "generation-4", + }); + + expect(rejectSuggestion(editor, "suggestion-block")).toBe(true); + expect(editor.getBlock("ai-block")).toBeNull(); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part1.test.ts b/packages/extensions/ai/src/__tests__/extension.part1.test.ts new file mode 100644 index 0000000..6d20719 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part1.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("marks inserted and deleted text in suggest mode", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "user" }, + ); + editor.apply( + [{ type: "delete-text", blockId, offset: 6, length: 5 }], + { origin: "user" }, + ); + + const block = editor.getBlock(blockId)!; + const deltas = block.textDeltas(); + + expect(deltas[0]?.attributes?.suggestion).toMatchObject({ + action: "insert", + author: "tester", + }); + expect(deltas[1]?.attributes?.suggestion).toMatchObject({ + action: "delete", + author: "tester", + }); + expect(block.textContent()).toBe("Hello world"); + expect(block.textContent({ resolved: true })).toBe("Hello "); + }); + + it("rejects persistent suggestions through the controller", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "user" }, + ); + + const controller = getAIController(editor)!; + const suggestionsSnapshot = controller.getSuggestions(); + const suggestion = suggestionsSnapshot[0]; + expect(suggestion).toBeDefined(); + expect(controller.getSuggestions()).toBe(suggestionsSnapshot); + + expect(rejectSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + }); + + it("accepts persistent suggestions without re-intercepting them", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + editor.apply( + [{ type: "delete-text", blockId, offset: 0, length: 5 }], + { origin: "user" }, + ); + + const [suggestion] = readSuggestionsFromBlock(editor, blockId); + expect(suggestion).toBeDefined(); + + expect(acceptSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + expect(readAllSuggestions(editor)).toEqual([]); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + }); + + it("keeps accepted delete suggestions in document undo history", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + editor.apply( + [{ type: "delete-text", blockId, offset: 0, length: 5 }], + { origin: "user" }, + ); + + const [suggestion] = readSuggestionsFromBlock(editor, blockId); + expect(suggestion).toBeDefined(); + expect(acceptSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello"); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.redo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + }); + + it("keeps rejected insert suggestions in document undo history", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const blockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "user" }, + ); + + const [suggestion] = readSuggestionsFromBlock(editor, blockId); + expect(suggestion).toBeDefined(); + expect(rejectSuggestion(editor, suggestion.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + + expect(editor.undoManager.redo()).toBe(true); + expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); + expect(readAllSuggestions(editor)).toHaveLength(1); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe(""); + expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); + }); + + it("accepts multiple suggestions in one undo group", () => { + const editor = createEditor({ + extensions: [aiExtension({ suggestMode: true, author: "tester" })], + }); + const firstBlockId = editor.firstBlock()!.id; + + editor.apply( + [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], + { origin: "user" }, + ); + editor.apply( + [ + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + ], + { origin: "user" }, + ); + + expect(readAllSuggestions(editor)).toHaveLength(2); + + acceptAllSuggestions(editor); + expect(readAllSuggestions(editor)).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(readAllSuggestions(editor)).toHaveLength(2); + + expect(editor.undoManager.redo()).toBe(true); + expect(readAllSuggestions(editor)).toEqual([]); + }); + + it("runs a block generation with a model adapter", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " world" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Continue", { blockId }); + + expect(generation.status).toBe("complete"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); + expect(controller.getState().activeGeneration?.text).toBe(" world"); + }); + + it("parses markdown block generations into structured blocks", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { blockGeneration: "markdown" }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "# Title\n\n- One", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const targetBlockId = "target-block"; + const trailingBlockId = "trailing-block"; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: targetBlockId, + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-block", + blockId: trailingBlockId, + blockType: "paragraph", + props: {}, + position: { after: targetBlockId }, + }, + { + type: "insert-text", + blockId: trailingBlockId, + offset: 0, + text: "Outro", + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")?.tableRowCount(); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Continue this paragraph", { + blockId: targetBlockId, + }); + const blockOrder = editor.documentState.blockOrder; + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("markdown"); + expect(blockOrder).toHaveLength(4); + expect(blockOrder).not.toContain(targetBlockId); + expect(editor.getBlock(blockOrder[0])?.textContent()).toBe("Intro"); + expect(editor.getBlock(blockOrder[1])?.type).toBe("heading"); + expect(editor.getBlock(blockOrder[1])?.textContent()).toBe("Title"); + expect(editor.getBlock(blockOrder[2])?.type).toBe("bulletListItem"); + expect(editor.getBlock(blockOrder[2])?.textContent()).toBe("One"); + expect(editor.getBlock(blockOrder[3])?.textContent()).toBe("Outro"); + }); + + it("runs a selection generation when text is selected", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Rewrite the selection"); + + expect(generation.status).toBe("complete"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello planet"); + expect(controller.getState().activeGeneration?.text).toBe("planet"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); + + it("uses selection-fast request mode for bottom-chat selection rewrites", async () => { + let requestMode: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream(options) { + requestMode = options.requestMode; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "selection", + }); + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + + expect(requestMode).toBe("selection-fast"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part10.test.ts b/packages/extensions/ai/src/__tests__/extension.part10.test.ts new file mode 100644 index 0000000..5a22092 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part10.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("treats whole-document rewrite prompts as explicit multi-block replace plans", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Founder's Last Email\n\nA startup story set in Amsterdam.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "The Lighthouse Keeper's Last Letter" }, + { + type: "insert-block", + blockId: "paragraph-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "paragraph-2", + offset: 0, + text: "The storm had been building for three days.", + }, + ], + { origin: "system" }, + ); + const originalBlockIds = [...editor.documentState.blockOrder]; + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the whole story. Make it about a startup from Amsterdam.", + { target: "document" }, + ); + + const activeSession = controller.getSessions().find((item) => item.id === session.id); + expect(activeSession?.operation?.kind).toBe("rewrite-selection"); + expect(activeSession?.operation?.target.kind).toBe("scoped-range"); + const documentTarget = + activeSession?.operation?.target.kind === "scoped-range" + ? activeSession.operation.target + : null; + expect(documentTarget?.blockIds).toEqual(originalBlockIds); + expect(documentTarget?.contentFormat).toBe("markdown"); + expect(documentTarget?.scope).toBe("document"); + expect(generation.status).toBe("complete"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = activeSession?.turns[0]?.id; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + + const finalVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(finalVisibleBlockTexts).toEqual([ + "The Founder's Last Email", + "A startup story set in Amsterdam.", + ]); + }); + + it("treats rewrite-the-story prompts as explicit multi-block replace plans", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Pharaoh's Last Scroll\n\nA cat story set in Egypt.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { + type: "insert-text", + blockId: firstBlockId, + offset: 0, + text: "The Founder's Last Email", + }, + { + type: "insert-block", + blockId: "paragraph-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "paragraph-2", + offset: 0, + text: "The Slack notification had been pinging for three days.", + }, + ], + { origin: "system" }, + ); + const originalBlockIds = [...editor.documentState.blockOrder]; + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the story. Make it about a cat from Egypt.", + { target: "document" }, + ); + + const activeSession = controller.getSessions().find((item) => item.id === session.id); + expect(activeSession?.operation?.kind).toBe("rewrite-selection"); + expect(activeSession?.operation?.target.kind).toBe("scoped-range"); + const documentTarget = + activeSession?.operation?.target.kind === "scoped-range" + ? activeSession.operation.target + : null; + expect(documentTarget?.blockIds).toEqual(originalBlockIds); + expect(documentTarget?.contentFormat).toBe("markdown"); + expect(documentTarget?.scope).toBe("document"); + expect(generation.status).toBe("complete"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = activeSession?.turns[0]?.id; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + + const finalVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(finalVisibleBlockTexts).toEqual([ + "The Pharaoh's Last Scroll", + "A cat story set in Egypt.", + ]); + }); + + it("carries bottom-chat history into follow-up title edits and replaces prior generated blocks", async () => { + const capturedPrompts: string[] = []; + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + streamCount += 1; + capturedPrompts.push( + options.messages + .map((message) => + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content), + ) + .join("\n\n"), + ); + yield { + type: "replace-final" as const, + operation: options.operation!, + text: + streamCount === 1 + ? "# Salt and Shadow\n\nA lighthouse story." + : "# Amsterdam Sprint\n\nA startup story with a new title.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + await controller.runSessionPrompt(session.id, "Write a story", { + target: "document", + }); + + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt( + session.id, + "Also change the title.", + { target: "document" }, + ); + + expect(capturedPrompts[1]).toContain( + "Earlier user requests in this same session:", + ); + expect(capturedPrompts[1]).toContain("1. Write a story"); + expect(capturedPrompts[1]).toContain( + "Latest request:\nAlso change the title.", + ); + const activeSession = controller.getSessions().find((item) => item.id === session.id); + expect(activeSession?.operation?.kind).toBe("rewrite-selection"); + expect(activeSession?.operation?.target.kind).toBe("scoped-range"); + const documentTarget = + activeSession?.operation?.target.kind === "scoped-range" + ? activeSession.operation.target + : null; + expect(documentTarget?.scope).toBe("heading"); + expect(documentTarget?.contentFormat).toBe("markdown"); + expect(documentTarget?.blockIds).toHaveLength(1); + }); + + it("replaces the previous story after accepting a follow-up make-it-about rewrite", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + streamCount += 1; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: + streamCount === 1 + ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." + : "# The Cat Keeper's Last Purr\n\nA cat story.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + await controller.runSessionPrompt(session.id, "Write a story", { + target: "document", + }); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt(session.id, "Actually make it about cats", { + target: "document", + }); + const secondTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.id ?? null; + expect(secondTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); + + const finalVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(finalVisibleBlockTexts).toEqual([ + "The Cat Keeper's Last Purr", + "A cat story.", + ]); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part11.test.ts b/packages/extensions/ai/src/__tests__/extension.part11.test.ts new file mode 100644 index 0000000..1fbd448 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part11.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("restores the previous accepted story when undoing a kept follow-up rewrite", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + streamCount += 1; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: + streamCount === 1 + ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." + : "# The Cat Keeper's Last Purr\n\nA cat story.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + await controller.runSessionPrompt(session.id, "Write a story", { + target: "document", + }); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt(session.id, "Actually make it about cats", { + target: "document", + }); + const secondTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.id ?? null; + expect(secondTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); + + expect(editor.undoManager.undo()).toBe(true); + + const visibleBlockTextsAfterUndo = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleBlockTextsAfterUndo).toEqual([ + "The Lighthouse Keeper's Last Signal", + "A lighthouse story.", + ]); + }); + + it("trims leading blank lines when bottom-chat writes into an empty block", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "\n\nOnce upon a time", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(visibleBlockTexts).toEqual(["Once upon a time"]); + }); + + it("materializes bottom-chat paragraphs as separate blocks for empty targets", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "First paragraph.\n\nSecond paragraph.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write two paragraphs", + { target: "document" }, + ); + + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(visibleBlockTexts).toEqual([ + "First paragraph.", + "Second paragraph.", + ]); + }); + + it("reuses a leading empty placeholder for document-target bottom-chat writes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "Story opener.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const placeholderBlockId = editor.firstBlock()!.id; + const trailingBlockId = "trailing-block"; + editor.apply( + [ + { + type: "insert-block", + blockId: trailingBlockId, + blockType: "paragraph", + props: {}, + position: { after: placeholderBlockId }, + }, + { + type: "insert-text", + blockId: trailingBlockId, + offset: 0, + text: "Existing content", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + const blockOrder = editor.documentState.blockOrder; + const visibleBlockTexts = blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(blockOrder).toHaveLength(3); + expect(visibleBlockTexts).toEqual(["Story opener.", "Existing content"]); + expect(readBlockSuggestionMeta(editor.getBlock(placeholderBlockId))?.action).toBe( + "delete-block", + ); + }); + + it("prefers the caret block over unrelated empty placeholders for document-target writes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "Follow the caret.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const placeholderBlockId = editor.firstBlock()!.id; + const caretBlockId = "caret-block"; + editor.apply( + [ + { + type: "insert-block", + blockId: caretBlockId, + blockType: "paragraph", + props: {}, + position: { after: placeholderBlockId }, + }, + { + type: "insert-text", + blockId: caretBlockId, + offset: 0, + text: "Existing content", + }, + ], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId: caretBlockId, offset: 8 }, + { blockId: caretBlockId, offset: 8 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Write more here", + { target: "document" }, + ); + const blockOrder = editor.documentState.blockOrder; + const visibleBlockTexts = blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(generation.status).toBe("complete"); + expect(blockOrder).toHaveLength(3); + expect(visibleBlockTexts).toEqual(["Existing content", "Follow the caret."]); + }); + + it("creates tables through markdown for bottom-chat document prompts", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "| Tier | Price |\n| --- | --- |\n| Pro | $20 |", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const introBlockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Create a pricing table", + { target: "document" }, + ); + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("markdown"); + expect(generation.planState).toBe("none"); + expect(generation.reviewItems).toEqual([]); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.blockClass).toBe("flow"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableCell(0, 0)?.textContent()).toBe("Tier"); + expect(tables[0]?.tableCell(0, 1)?.textContent()).toBe("Price"); + expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Pro"); + expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("$20"); + expect(controller.acceptActiveGeneration()).toBe(true); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part12.test.ts b/packages/extensions/ai/src/__tests__/extension.part12.test.ts new file mode 100644 index 0000000..9725549 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part12.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("streams markdown table suggestions before completion for bottom-chat document prompts", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: + "| First Name | Last Name |\n| --- | --- |\n| Alice | Johnson |", + }; + await releaseFinalDelta.promise; + yield { + type: "text-delta" as const, + delta: "\n| Bob | Smith |", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const introBlockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generationPromise = controller.runSessionPrompt( + session.id, + "Create a table with names in it", + { target: "document" }, + ); + + await waitForPreview(() => { + const tables = Array.from(editor.blocks("table")); + return tables[0]?.tableCell(1, 0)?.textContent() === "Alice"; + }); + + expect(controller.getState().activeGeneration?.adapterId).toBe("flow-markdown"); + expect(controller.getState().activeGeneration?.blockClass).toBe("flow"); + expect(controller.getState().activeGeneration?.transportKind).toBe("flow-text"); + expect(controller.getState().activeGeneration?.mutationMode).toBe( + "streaming-suggestions", + ); + const previewTables = Array.from(editor.blocks("table")); + expect(previewTables).toHaveLength(1); + expect(previewTables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); + expect(previewTables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); + + releaseFinalDelta.resolve(); + const generation = await generationPromise; + + expect(generation.planState).toBe("none"); + expect(generation.reviewItems).toEqual([]); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.blockClass).toBe("flow"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); + expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); + expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); + expect(tables[0]?.tableCell(2, 1)?.textContent()).toBe("Smith"); + }); + + it("builds rich preview details for newly inserted databases during direct bottom-chat apply", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "review_bundle", + label: "Create task database", + reason: "Insert and seed a task database.", + plans: [ + { + kind: "block_insert", + blockId: "task-db", + blockType: "database", + position: "last", + }, + { + kind: "database_edit", + blockId: "task-db", + steps: [ + { + op: "insert_row", + rowId: "row-1", + values: { + name: "Ship docs", + tags: "[\"docs\"]", + done: "false", + }, + }, + { + op: "add_view", + view: { + id: "view-list", + title: "List view", + type: "list", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + }, + }, + ], + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Create a task database table with views", + { target: "document" }, + ); + + expect(generation.planState).toBe("validated"); + expect(generation.structuredPreview?.targets).toEqual([ + expect.objectContaining({ + blockId: "task-db", + targetKind: "database", + database: expect.objectContaining({ + columns: expect.arrayContaining([ + expect.objectContaining({ id: "name" }), + expect.objectContaining({ id: "tags" }), + expect.objectContaining({ id: "done" }), + ]), + rows: [ + expect.objectContaining({ + id: "row-1", + values: expect.objectContaining({ + name: "Ship docs", + }), + }), + ], + views: expect.arrayContaining([ + expect.objectContaining({ id: "view-table" }), + expect.objectContaining({ id: "view-list" }), + ]), + }), + }), + ]); + expect(generation.reviewItems).toEqual([]); + expect(editor.getBlock("task-db")?.type).toBe("database"); + }); + + it("replaces existing tables through markdown suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "| Name |", + "| --- |", + "| Alice |", + "| Bob |", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Add a row to this table", { + blockId: "table-1", + }); + + expect(generation.status).toBe("complete"); + expect(generation.targetKind).toBe("table"); + expect(generation.planState).toBe("none"); + expect(generation.plan).toBeNull(); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.reviewItems).toEqual([]); + expect(generation.debug?.structured).toMatchObject({ + plannerMode: "text", + targetKind: "table", + validationIssueCount: 0, + }); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + expect(editor.getBlock("table-1")?.tableRowCount()).toBe(initialRowCount); + }); + + it("accepts markdown table suggestions through the controller", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "| Name |", + "| --- |", + "| Alice |", + "| Bob |", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); + + const controller = getAIController(editor)!; + await controller.runPrompt("Add a row to this table", { + blockId: "table-1", + }); + + expect(controller.acceptActiveGeneration()).toBe(true); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableRowCount()).toBe(initialRowCount + 1); + expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); + expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); + expect(controller.getState().activeGeneration?.plan).toBeNull(); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + }); + + it("rejects markdown table suggestions without mutating the table", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "| Name |", + "| --- |", + "| Alice |", + "| Bob |", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); + + const controller = getAIController(editor)!; + await controller.runPrompt("Add a row to this table", { + blockId: "table-1", + }); + + expect(controller.rejectActiveGeneration()).toBe(true); + expect(editor.getBlock("table-1")!.tableRowCount()).toBe(initialRowCount); + expect(Array.from(editor.blocks("table"))).toHaveLength(1); + expect(controller.getState().activeGeneration?.plan).toBeNull(); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + expect(controller.getState().activeGeneration?.planState).toBe("rejected"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part13.test.ts b/packages/extensions/ai/src/__tests__/extension.part13.test.ts new file mode 100644 index 0000000..6d6a782 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part13.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("applies XML flow patch plans through the markdown fast-apply path", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: [ + "", + "I am replacing the current table with an updated version.", + "adjacent-blocks", + "span:table-1", + "", + "replace_blocks", + "table-1", + "table", + "", + "", + "", + ].join("\n"), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "table-1", + blockType: "table", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Add a role column to this table", { + blockId: "table-1", + }); + + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + + expect(controller.acceptActiveGeneration()).toBe(true); + const tables = Array.from(editor.blocks("table")); + expect(tables).toHaveLength(1); + expect(tables[0]?.tableColumnCount()).toBe(2); + expect(tables[0]?.tableRowCount()).toBe(3); + expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Design"); + }); + + it("records flow patch alignment metrics in fast-apply debug state", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Bravo", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const controllerAny = controller as any; + controllerAny._state.activeGeneration = { + id: "test-generation", + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + + const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( + firstBlockId, + [ + "", + "I am inserting a new paragraph between Bravo and Charlie.", + "adjacent-blocks", + `span:${firstBlockId}`, + "", + "replace_blocks", + `${firstBlockId}`, + "block-2", + "block-3", + "", + "", + "", + ].join("\n"), + "persistent-suggestions", + undefined, + { + context: { + markdown: ["Alpha", "", "Bravo", "", "Charlie"].join("\n"), + markdownWindow: { + blockIds: [firstBlockId, "block-2", "block-3"], + }, + }, + }, + ); + + expect(mutationReceipt?.status).toBe("staged_suggestions"); + expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + alignment: { + preservedBlockCount: 3, + rewrittenBlockCount: 0, + unchangedBlockCount: 3, + insertedBlockCount: 1, + deletedBlockCount: 0, + estimatedOperationCost: 2, + }, + }); + }); + + it("records scoped replacement fallback metrics in fast-apply debug state", () => { + const editor = createEditor({ + extensions: [aiExtension({})], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const controllerAny = controller as any; + controllerAny._state.activeGeneration = { + id: "test-generation", + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + + const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( + firstBlockId, + [ + "", + "I am inserting a middle paragraph.", + "", + "", + "", + "", + "Bravo", + "", + "]]>", + "", + ].join("\n"), + "persistent-suggestions", + undefined, + { + context: { + markdown: ["Alpha", "", "Charlie"].join("\n"), + markdownWindow: { + blockIds: [firstBlockId, "block-2"], + }, + }, + }, + ); + + expect(mutationReceipt?.status).toBe("staged_suggestions"); + expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + fallback: { + kind: "scoped-replacement", + opsCount: 8, + insertedBlockCount: 3, + deletedBlockCount: 2, + targetBlockCount: 2, + }, + }); + }); + + it("records plain markdown fallback metrics when fast-apply falls back to block generation", () => { + const editor = createEditor({ + extensions: [aiExtension({ contentFormat: { blockGeneration: "markdown" } })], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const controllerAny = controller as any; + controllerAny._state.activeGeneration = { + id: "test-generation", + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + + const mutationReceipt = controllerAny._commitBufferedBlockGeneration( + firstBlockId, + "## Replacement title", + "persistent-suggestions", + "markdown", + undefined, + { + applyStrategy: "markdown-fast-apply", + workingSet: { + context: { + markdown: "Hello", + markdownWindow: { + blockIds: [firstBlockId], + }, + }, + }, + }, + ); + + expect(mutationReceipt?.status).toBe("staged_suggestions"); + expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ + attempted: true, + succeeded: false, + fallbackReason: "unparseable-contract", + executionPath: "plain-markdown", + fallback: { + kind: "plain-markdown", + opsCount: 2, + insertedBlockCount: 1, + deletedBlockCount: 0, + }, + }); + }); + + it("executes review-safe block convert plans through the existing suggestion path", async () => { + let blockId = ""; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "block_convert", + blockId, + newType: "heading", + props: { level: 2 }, + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Convert block to heading", { + blockId, + }); + const block = editor.getBlock(blockId)!; + + expect(generation.planState).toBe("validated"); + expect(generation.plan).toMatchObject({ + kind: "block_convert", + blockId, + newType: "heading", + }); + expect(block.type).toBe("heading"); + expect(block.meta("suggestion")).toMatchObject({ + action: "convert-block", + authorType: "ai", + }); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part14.test.ts b/packages/extensions/ai/src/__tests__/extension.part14.test.ts new file mode 100644 index 0000000..b1c199a --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part14.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("keeps the controller state snapshot stable for no-op updates", () => { + const editor = createEditor({ + extensions: [aiExtension()], + }); + + const controller = getAIController(editor)!; + const initialState = controller.getState(); + + controller.setSuggestMode(false); + expect(controller.getState()).toBe(initialState); + + controller.closeCommandMenu(); + expect(controller.getState()).toBe(initialState); + + controller.dismissEphemeralSuggestion(); + expect(controller.getState()).toBe(initialState); + }); + + it("builds database review items with before and after cell previews", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + + expect(generation.reviewItems).toEqual([ + expect.objectContaining({ + label: "Update cell", + changeKind: "updated", + section: "cell", + detail: "Alpha · Name", + before: "Alpha", + after: "Beta", + }), + ]); + }); + + it("keeps accepted structured review items in document undo history", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + const reviewItems = generation.reviewItems ?? []; + const reviewItemIds = reviewItems.map((item) => item.id); + + expect(generation.planState).toBe("validated"); + expect(reviewItems).toHaveLength(1); + expect(reviewItemIds).toHaveLength(1); + + expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Beta", + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Alpha", + ); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Beta", + ); + }); + + it("treats structured review rejection as non-mutating UI state", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + const reviewItems = generation.reviewItems ?? []; + const reviewItemIds = reviewItems.map((item) => item.id); + + expect(reviewItemIds).toHaveLength(1); + expect(controller.rejectReviewItems(reviewItemIds)).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Alpha", + ); + expect(editor.undoManager.canUndo()).toBe(false); + expect(editor.undoManager.undo()).toBe(false); + expect(controller.getState().activeGeneration?.planState).toBe("rejected"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + }); + + it("keeps accepted structured review artifacts transient across history replay", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: "Beta", + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { + name: "Alpha", + tags: "[]", + done: "false", + }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Update this database cell", { + blockId: "database-1", + }); + const reviewItems = generation.reviewItems ?? []; + const reviewItemIds = reviewItems.map((item) => item.id); + + expect(reviewItemIds).toHaveLength(1); + expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Alpha", + ); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + + expect(editor.undoManager.redo()).toBe(true); + expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( + "Beta", + ); + expect(controller.getState().activeGeneration?.planState).toBe("none"); + expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part15.test.ts b/packages/extensions/ai/src/__tests__/extension.part15.test.ts new file mode 100644 index 0000000..c6111c9 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part15.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("builds comparison rows for database view changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: JSON.stringify({ + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "add_view", + view: { + id: "view-list", + title: "List view", + type: "list", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + filter: null, + groupBy: "tags", + pageIndex: 0, + pageSize: 50, + }, + }, + ], + }), + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: { after: firstBlockId }, + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Add a grouped list view", { + blockId: "database-1", + }); + + expect(generation.reviewItems).toEqual([ + expect.objectContaining({ + label: "Add view", + comparisonRows: expect.arrayContaining([ + expect.objectContaining({ + label: "View", + after: "List view", + changeKind: "added", + section: "view", + }), + expect.objectContaining({ + label: "Group by", + after: "Tags", + changeKind: "updated", + section: "view", + }), + expect.objectContaining({ + label: "Visible columns", + after: "Name, Tags", + changeKind: "updated", + section: "view", + }), + ]), + }), + ]); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part2.test.ts b/packages/extensions/ai/src/__tests__/extension.part2.test.ts new file mode 100644 index 0000000..62caf49 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part2.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("keeps document-targeted bottom-chat rewrites off selection-fast even with a live selection", async () => { + let requestMode: string | undefined; + let operationKind: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + requestMode = options.requestMode; + operationKind = options.operation?.kind; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(requestMode).toBe("selection-fast"); + expect(operationKind).toBe("rewrite-selection"); + }); + + it("routes bottom-chat block rewrites through typed local replace operations", async () => { + let requestMode: string | undefined; + let operationKind: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + requestMode = options.requestMode; + operationKind = options.operation?.kind; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "Hello planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(requestMode).toBe("selection-fast"); + expect(operationKind).toBe("rewrite-selection"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("routes whole-document rewrites through typed local replace operations", async () => { + let operation: + | { + kind?: string; + target?: { + kind?: string; + blockIds?: readonly string[]; + contentFormat?: string; + scope?: string; + }; + } + | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + operation = options.operation; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Cat Keeper\n\nA cat story.", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper" }, + { type: "convert-block", blockId, newType: "heading" }, + { + type: "insert-block", + blockId: "paragraph-1", + blockType: "paragraph", + props: {}, + position: { after: blockId }, + }, + { + type: "insert-text", + blockId: "paragraph-1", + offset: 0, + text: "A lighthouse story.", + }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the whole story. Make it about cats.", + { target: "document" }, + ); + + expect(operation?.kind).toBe("rewrite-selection"); + expect(operation?.target?.kind).toBe("scoped-range"); + expect(operation?.target?.contentFormat).toBe("markdown"); + expect(operation?.target?.scope).toBe("document"); + expect(operation?.target?.blockIds).toContain("paragraph-1"); + expect(operation?.target?.blockIds).toHaveLength(2); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleBlockTexts).toEqual(["The Cat Keeper", "A cat story."]); + }); + + it("routes remove-all document edits through typed local delete suggestions", async () => { + let operation: + | { + kind?: string; + target?: { + kind?: string; + blockIds?: readonly string[]; + contentFormat?: string; + scope?: string; + }; + } + | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + operation = options.operation; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Remove all content in the document.", + { target: "document" }, + ); + + expect(operation).toMatchObject({ + kind: "rewrite-selection", + target: { + kind: "scoped-range", + blockIds: editor.documentState.blockOrder, + contentFormat: "markdown", + scope: "document", + }, + }); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleBlockTexts).toEqual([]); + }); + + it("keeps heading rewrites block-bounded instead of inserting a new markdown block", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# The Keeper's Final Watch", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper's Last Night" }, + { type: "convert-block", blockId, newType: "heading" }, + ], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + const turnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(turnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + expect(editor.firstBlock()?.type).toBe("heading"); + expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( + "The Keeper's Final Watch", + ); + expect(editor.documentState.blockOrder).toHaveLength(1); + }); + + it("routes bottom-chat continue prompts to typed insert operations", async () => { + let operationKind: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + operationKind = options.operation?.kind; + yield { + type: "insert-final" as const, + operation: options.operation!, + text: " and beyond", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + await controller.runSessionPrompt(session.id, "Continue writing"); + + expect(operationKind).toBe("continue-block"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello world and beyond", + ); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part3.test.ts b/packages/extensions/ai/src/__tests__/extension.part3.test.ts new file mode 100644 index 0000000..0860d9b --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part3.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("falls back to document review mode for bottom-chat rewrites on non-text blocks", async () => { + let requestMode: string | undefined; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + }, + model: { + async *stream(options) { + requestMode = options.requestMode; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId, offset: 0, text: "Hello table" }, + { type: "convert-block", blockId, newType: "table", newProps: {} }, + ], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + + expect(requestMode).toBe("selection-fast"); + expect(generation.route).toBe("selection-rewrite"); + expect(generation.mutationReceipt?.status).toBe("noop"); + expect(editor.getBlock(blockId)?.type).toBe("table"); + }); + + it("marks local bottom-chat rewrites invalid when target provenance changes", async () => { + const releaseFinalFrame = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + yield { + type: "replace-preview" as const, + operation: options.operation!, + text: "Hello planet", + }; + await releaseFinalFrame.promise; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "Hello planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generationPromise = controller.runSessionPrompt(session.id, "Rewrite this"); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + editor.apply( + [{ type: "insert-text", blockId, offset: 11, text: "!" }], + { origin: "user" }, + ); + releaseFinalFrame.resolve(); + const generation = await generationPromise; + + expect(generation.mutationReceipt?.status).toBe("invalid"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello world!", + ); + }); + + it("accepts typed local bottom-chat document rewrites", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "text", + }, + model: { + async *stream(options) { + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "# Hello planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + }); + const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); + expect(generation.status).toBe("complete"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + }); + + it("streams selection rewrites into persistent suggestions before completion", async () => { + const releaseSecondDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "plan" }; + await releaseSecondDelta.promise; + yield { type: "text-delta" as const, delta: "et" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const generationPromise = controller.runPrompt("Rewrite the selection"); + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + + expect(controller.getState().ephemeralSuggestion).toBeNull(); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplan"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + + releaseSecondDelta.resolve(); + const generation = await generationPromise; + + expect(generation.status).toBe("complete"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); + }); + + it("tracks session prompts and accepts session suggestions together", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the selection", + ); + const nextSession = controller.getActiveSession(); + + expect(generation.sessionId).toBe(session.id); + expect(nextSession?.promptHistory).toHaveLength(1); + expect(nextSession?.turns).toHaveLength(1); + expect(nextSession?.turns[0]?.generationId).toBe(generation.id); + expect(nextSession?.turns[0]?.status).toBe("review"); + expect(nextSession?.generationIds).toContain(generation.id); + expect(nextSession?.pendingSuggestionIds.length).toBeGreaterThan(0); + expect(controller.acceptSessionTurn(session.id, nextSession!.turns[0]!.id)).toBe(true); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("includes prior inline prompts when continuing the same inline edit session", async () => { + const capturedPrompts: string[] = []; + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream(options) { + capturedPrompts.push(String(options.messages[0]?.content ?? "")); + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "forest", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 12 }, + ); + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + expect(capturedPrompts[1]).toContain( + "You are continuing an existing inline editor edit session.", + ); + expect(capturedPrompts[1]).toContain( + "Earlier user requests in this same session:", + ); + expect(capturedPrompts[1]).toContain("1. Rewrite the selection"); + expect(capturedPrompts[1]).toContain( + "Latest request:\nMake it more whimsical", + ); + }); + + it("refreshes the inline follow-up target after accepting a rewritten selection", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream(options) { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + const firstTurnId = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[0]?.id ?? null; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); + + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + const secondOperation = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.operation; + expect(secondOperation?.kind).toBe("rewrite-selection"); + expect(secondOperation?.target.kind).toBe("selection"); + if (secondOperation?.target.kind !== "selection") { + throw new Error("Expected selection target for inline follow-up"); + } + expect(secondOperation.target.sourceText).toBe("planet"); + expect( + secondOperation.target.focus.offset - secondOperation.target.anchor.offset, + ).toBeGreaterThanOrEqual("planet".length); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part4.test.ts b/packages/extensions/ai/src/__tests__/extension.part4.test.ts new file mode 100644 index 0000000..7974f36 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part4.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("refreshes the inline follow-up target after keeping a rewritten selection", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + expect(controller.acceptActiveGeneration()).toBe(true); + + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + const secondOperation = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.operation; + expect(secondOperation?.kind).toBe("rewrite-selection"); + expect(secondOperation?.target.kind).toBe("selection"); + if (secondOperation?.target.kind !== "selection") { + throw new Error("Expected selection target for kept inline follow-up"); + } + expect(secondOperation.target.sourceText).toBe("planet"); + expect( + secondOperation.target.focus.offset - secondOperation.target.anchor.offset, + ).toBeGreaterThanOrEqual("planet".length); + }); + + it("refreshes the inline follow-up target while the prior turn is still in review", async () => { + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + expect( + controller.getSessions().find((item) => item.id === session.id)?.turns[0]?.status, + ).toBe("review"); + + await controller.runSessionPrompt(session.id, "Make it more whimsical"); + + const secondOperation = + controller + .getSessions() + .find((item) => item.id === session.id) + ?.turns[1]?.operation; + expect(secondOperation?.kind).toBe("rewrite-selection"); + expect(secondOperation?.target.kind).toBe("selection"); + if (secondOperation?.target.kind !== "selection") { + throw new Error("Expected selection target for inline follow-up review"); + } + expect(secondOperation.target.sourceText).toBe("planet"); + expect( + secondOperation.target.focus.offset - secondOperation.target.anchor.offset, + ).toBeGreaterThanOrEqual("planet".length); + }); + + it("keeps inline prompt targets stable after the live selection changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + editor.selectTextRange( + { blockId, offset: 11 }, + { blockId, offset: 11 }, + ); + + const originalSelectTextRange = editor.selectTextRange.bind(editor); + editor.selectTextRange = () => { + // Simulate a selection target that can no longer be reselected from live state. + }; + + try { + const generation = await controller.runSessionPrompt( + session!.id, + "Rewrite the selection", + ); + + expect(generation.status).toBe("complete"); + expect(generation.target).toBe("selection"); + expect( + controller + .getSessions() + .find((item) => item.id === session!.id) + ?.turns[0]?.target, + ).toBe("selection"); + } finally { + editor.selectTextRange = originalSelectTextRange; + } + }); + + it("restores inline edit review state through document undo and redo", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + + const acceptedSession = controller.getActiveSession(); + expect(acceptedSession?.contextualPrompt?.composer.isOpen).toBe(false); + expect(acceptedSession?.turns[0]?.status).toBe("accepted"); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.turns).toHaveLength(1); + expect(restoredSession?.turns[0]?.status).toBe("review"); + expect(restoredSession?.turns[0]?.suggestionIds.length ?? 0).toBeGreaterThan(0); + + expect(editor.undoManager.redo()).toBe(true); + + const redoneSession = controller.getActiveSession(); + expect(redoneSession?.id).toBe(session!.id); + expect(redoneSession?.contextualPrompt?.composer.isOpen).toBe(false); + expect(redoneSession?.turns[0]?.status).toBe("accepted"); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("lets inline edit continue from an undone review state", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + pass += 1; + yield { + type: "text-delta" as const, + delta: pass === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + + await controller.runSessionPrompt(session!.id, "Try another rewrite"); + + const resumedSession = controller.getActiveSession(); + expect(resumedSession?.turns).toHaveLength(2); + expect(resumedSession?.turns[1]?.prompt).toBe("Try another rewrite"); + expect(resumedSession?.turns[1]?.status).toBe("review"); + }); + + it("undoes a streamed inline turn as one step even when deltas span capture timeouts", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "pla" }; + await new Promise((resolve) => setTimeout(resolve, 550)); + yield { type: "text-delta" as const, delta: "net" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + + expect(editor.undoManager.undo()).toBe(true); + const restoredReviewSession = controller.getActiveSession(); + expect(restoredReviewSession?.id).toBe(session!.id); + expect(restoredReviewSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredReviewSession?.turns).toHaveLength(1); + expect(restoredReviewSession?.turns[0]?.status).toBe("review"); + + expect(editor.undoManager.undo()).toBe(true); + const restoredPromptSession = controller.getActiveSession(); + expect(restoredPromptSession?.id).toBe(session!.id); + expect(restoredPromptSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredPromptSession?.turns).toHaveLength(0); + expect(restoredPromptSession?.contextualPrompt?.composer.draftPrompt).toBe( + "Rewrite the selection", + ); + + expect(editor.undoManager.redo()).toBe(true); + const redoneReviewSession = controller.getActiveSession(); + expect(redoneReviewSession?.turns).toHaveLength(1); + expect(redoneReviewSession?.turns[0]?.status).toBe("review"); + + expect(editor.undoManager.redo()).toBe(true); + const redoneAcceptedSession = controller.getActiveSession(); + expect(redoneAcceptedSession?.turns[0]?.status).toBe("accepted"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part5.test.ts b/packages/extensions/ai/src/__tests__/extension.part5.test.ts new file mode 100644 index 0000000..ab00ad1 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part5.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("does not reopen accepted inline review for unrelated undo operations", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + editor.apply( + [ + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello world" }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: secondBlockId, offset: 0, text: "Other block" }, + ], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId: firstBlockId, offset: 6 }, + { blockId: firstBlockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( + false, + ); + editor.undoManager.stopCapturing(); + + editor.selectText(secondBlockId, 11, 11); + editor.apply( + [{ type: "insert-text", blockId: secondBlockId, offset: 11, text: "!" }], + { origin: "user" }, + ); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(secondBlockId)?.textContent()).toBe("Other block"); + expect(controller.getActiveSession()?.id).toBe(session!.id); + expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( + false, + ); + expect(controller.getActiveSession()?.turns[0]?.status).toBe("accepted"); + }); + + it("restores the latest inline review turn even when no inline session is active", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + controller.suspendInlineSession(session!.id); + expect(controller.getState().activeSessionId).toBeNull(); + + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.turns[0]?.status).toBe("review"); + }); + + it("restores the inline prompt in the same undo step after accepting a suspended review turn", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + + controller.suspendInlineSession(session!.id); + expect(controller.getState().activeSessionId).toBeNull(); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + expect(editor.undoManager.undo()).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( + "Rewrite the selection", + ); + expect(restoredSession?.turns).toHaveLength(1); + expect(restoredSession?.turns[0]?.status).toBe("review"); + }); + + it("restores prompt and review state on the first inline history undo shortcut", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite the selection"); + const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(reviewTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); + + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + expect(inlineHistory.handleShortcut("undo")).toBe(true); + + const restoredSession = controller.getActiveSession(); + expect(restoredSession?.id).toBe(session!.id); + expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); + expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( + "Rewrite the selection", + ); + expect(restoredSession?.turns).toHaveLength(1); + expect(restoredSession?.turns[0]?.status).toBe("review"); + }); + + it("rewrites text that was previously accepted from AI", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + pass += 1; + yield { + type: "text-delta" as const, + delta: pass === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const firstSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(firstSession).not.toBeNull(); + + await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); + const firstTurnId = controller.getActiveSession()?.turns[0]?.id; + expect(firstTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(firstSession!.id, firstTurnId!)).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 12 }, + ); + const secondSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(secondSession).not.toBeNull(); + expect(secondSession?.id).not.toBe(firstSession?.id); + + await controller.runSessionPrompt(secondSession!.id, "Rewrite the selection"); + const secondTurnId = controller.getActiveSession()?.turns.at(-1)?.id; + expect(secondTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(secondSession!.id, secondTurnId!)).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello galaxy", + ); + }); + + it("records selection rewrites in session fast-apply metrics", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + await controller.runSessionPrompt(session.id, "Rewrite the selection"); + + expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ + attemptCount: 1, + nativeFastApplyCount: 1, + scopedReplacementCount: 0, + plainMarkdownCount: 0, + failedCount: 0, + }); + }); + + it("accumulates fast-apply outcome counters across session turns", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ contentFormat: { blockGeneration: "markdown" } }), + ], + }); + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "block", + }); + const controllerAny = controller as any; + + controllerAny._recordSessionFastApplyMetrics(session.id, { + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + }); + controllerAny._recordSessionFastApplyMetrics(session.id, { + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + }); + controllerAny._recordSessionFastApplyMetrics(session.id, { + attempted: true, + succeeded: false, + executionPath: "plain-markdown", + fallbackReason: "unparseable-contract", + }); + + expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ + attemptCount: 3, + nativeFastApplyCount: 1, + scopedReplacementCount: 1, + plainMarkdownCount: 1, + failedCount: 0, + }); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part6.test.ts b/packages/extensions/ai/src/__tests__/extension.part6.test.ts new file mode 100644 index 0000000..f88e94d --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part6.test.ts @@ -0,0 +1,691 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + applySuggestedAIOperations, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("only restores a suspended inline session through history", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + + expect(session).not.toBeNull(); + controller.suspendInlineSession(session!.id); + expect(controller.getState().activeSessionId).toBeNull(); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + expect(controller.getState().activeSessionId).toBeNull(); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(false); + + editor.internals.emit("historyApplied", { + kind: "undo", + selection: editor.selection, + focusBlockId: blockId, + requestId: 1, + }); + + expect(controller.getState().activeSessionId).toBe(session!.id); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(true); + + controller.suspendInlineSession(session!.id); + editor.internals.emit("historyApplied", { + kind: "redo", + selection: editor.selection, + focusBlockId: blockId, + requestId: 2, + }); + + expect(controller.getState().activeSessionId).toBe(session!.id); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(true); + }); + + it("records inline history at settled turn checkpoints instead of stream chunks", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "pla" }; + yield { type: "text-delta" as const, delta: "net" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + const inlineHistory = getAIInlineHistoryController(editor)!; + + await controller.runSessionPrompt(session!.id, "Rewrite this"); + controller.suspendInlineSession(session!.id); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + expect(controller.getState().sessions[0]?.turns[0]?.status).toBe("review"); + + expect(inlineHistory.undoInlineHistory()).toBe(true); + expect(controller.getState().activeSessionId).toBe(session!.id); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, + ).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.undoInlineHistory()).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(0); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer.draftPrompt, + ).toBe("Rewrite this"); + }); + + it("cycles selection inline turn history one turn at a time through shortcuts", async () => { + let turnIndex = 0; + const turnOutputs = ["planet", "galaxy"]; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; + yield { type: "done" as const }; + turnIndex += 1; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "First rewrite"); + controller.suspendInlineSession(session!.id); + await controller.runSessionPrompt(session!.id, "Second rewrite"); + controller.suspendInlineSession(session!.id); + + expect(controller.getState().sessions[0]?.turns).toHaveLength(2); + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions).toHaveLength(0); + expect(controller.getState().activeSessionId).toBeNull(); + + expect(inlineHistory.canHandleShortcut("redo")).toBe(true); + expect(inlineHistory.handleShortcut("redo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.handleShortcut("redo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(2); + }); + + it("undoes and redoes a server-authored inline turn result without a local undo stack item", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Rewrite this"); + const turn = controller.getState().sessions[0]?.turns[0]; + expect(turn).toBeTruthy(); + + const operations = [ + { + type: "replace-text" as const, + blockId, + offset: 6, + length: 5, + text: "planet", + }, + ]; + const applyResult = applySuggestedAIOperations(editor, { + operations, + sessionId: session!.id, + turnId: turn!.id, + generationId: "server-generation-1", + origin: "system", + }); + (controller as any)._syncSuggestionsFromDocument(); + (controller as any)._updateSessionTurn(session!.id, turn!.id, { + status: "review", + generationId: "server-generation-1", + suggestionIds: applyResult.suggestionIds, + }); + expect( + controller.registerExternalInlineTurnResult({ + sessionId: session!.id, + turnId: turn!.id, + historyId: "server-generation-1", + operations, + suggestionIds: applyResult.suggestionIds, + }), + ).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + expect(editor.undoManager.canUndo()).toBe(false); + + expect(controller.undoInlineHistory()).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello world", + ); + expect(controller.getState().sessions[0]?.turns).toHaveLength(0); + + expect(controller.redoInlineHistory()).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + expect(controller.getState().sessions[0]?.turns[0]?.status).toBe( + "review", + ); + }); + + it("undoes a server-authored result when the prompt has a newer UI-only snapshot", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + await controller.runSessionPrompt(session!.id, "Rewrite this"); + const turn = controller.getState().sessions[0]?.turns[0]; + const operations = [ + { + type: "replace-text" as const, + blockId, + offset: 6, + length: 5, + text: "planet", + }, + ]; + const applyResult = applySuggestedAIOperations(editor, { + operations, + sessionId: session!.id, + turnId: turn!.id, + generationId: "server-generation-1", + origin: "system", + }); + (controller as any)._syncSuggestionsFromDocument(); + (controller as any)._updateSessionTurn(session!.id, turn!.id, { + status: "review", + generationId: "server-generation-1", + suggestionIds: applyResult.suggestionIds, + }); + controller.registerExternalInlineTurnResult({ + sessionId: session!.id, + turnId: turn!.id, + historyId: "server-generation-1", + operations, + suggestionIds: applyResult.suggestionIds, + }); + controller.updateContextualPromptDraft(session!.id, "Make it warmer"); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + expect((controller as any).handleInlineHistoryShortcut("undo")).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello world", + ); + }); + + it("walks server-authored inline turn results one turn at a time", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }], { + origin: "system", + }); + + const controller = getAIController(editor)!; + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 5 }); + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "Add greeting detail"); + const firstTurn = controller.getState().sessions[0]?.turns[0]; + const firstOperations = [ + { type: "insert-text" as const, blockId, offset: 5, text: " there" }, + ]; + const firstApplyResult = applySuggestedAIOperations(editor, { + operations: firstOperations, + sessionId: session!.id, + turnId: firstTurn!.id, + generationId: "server-generation-1", + origin: "system", + }); + (controller as any)._syncSuggestionsFromDocument(); + (controller as any)._updateSessionTurn(session!.id, firstTurn!.id, { + status: "review", + generationId: "server-generation-1", + suggestionIds: firstApplyResult.suggestionIds, + }); + controller.registerExternalInlineTurnResult({ + sessionId: session!.id, + turnId: firstTurn!.id, + historyId: "server-generation-1", + operations: firstOperations, + suggestionIds: firstApplyResult.suggestionIds, + }); + + await controller.runSessionPrompt(session!.id, "Add recipient detail"); + const secondTurn = controller.getState().sessions[0]?.turns[1]; + const secondOperations = [ + { type: "insert-text" as const, blockId, offset: 11, text: " friend" }, + ]; + const secondApplyResult = applySuggestedAIOperations(editor, { + operations: secondOperations, + sessionId: session!.id, + turnId: secondTurn!.id, + generationId: "server-generation-2", + origin: "system", + }); + (controller as any)._syncSuggestionsFromDocument(); + (controller as any)._updateSessionTurn(session!.id, secondTurn!.id, { + status: "review", + generationId: "server-generation-2", + suggestionIds: secondApplyResult.suggestionIds, + }); + controller.registerExternalInlineTurnResult({ + sessionId: session!.id, + turnId: secondTurn!.id, + historyId: "server-generation-2", + operations: secondOperations, + suggestionIds: secondApplyResult.suggestionIds, + }); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello there friend", + ); + const firstUndoTargetIndex = (controller as any)._resolveInlineHistoryTargetIndex( + "undo", + { shortcutOnly: true }, + ); + expect( + (controller as any)._inlineHistory[firstUndoTargetIndex]?.sessions[0] + ?.turns, + ).toHaveLength(1); + expect( + (controller as any)._inlineHistory[firstUndoTargetIndex]?.sessions[0] + ?.turns[0]?.status, + ).toBe("review"); + expect((controller as any).handleInlineHistoryShortcut("undo")).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello there", + ); + + expect((controller as any).handleInlineHistoryShortcut("redo")).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello there friend", + ); + }); + + it("builds per-turn external history when multiple server turns hydrate together", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply([{ type: "insert-text", blockId, offset: 0, text: "Hello" }], { + origin: "system", + }); + + const controller = getAIController(editor)!; + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 5 }); + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + await controller.runSessionPrompt(session!.id, "Add greeting detail"); + const firstTurn = controller.getState().sessions[0]?.turns[0]; + const firstOperations = [ + { type: "insert-text" as const, blockId, offset: 5, text: " there" }, + ]; + const firstApplyResult = applySuggestedAIOperations(editor, { + operations: firstOperations, + sessionId: session!.id, + turnId: firstTurn!.id, + generationId: "server-generation-1", + origin: "system", + }); + (controller as any)._syncSuggestionsFromDocument(); + (controller as any)._updateSessionTurn(session!.id, firstTurn!.id, { + status: "review", + generationId: "server-generation-1", + suggestionIds: firstApplyResult.suggestionIds, + }); + + await controller.runSessionPrompt(session!.id, "Add recipient detail"); + const secondTurn = controller.getState().sessions[0]?.turns[1]; + const secondOperations = [ + { type: "insert-text" as const, blockId, offset: 11, text: " friend" }, + ]; + const secondApplyResult = applySuggestedAIOperations(editor, { + operations: secondOperations, + sessionId: session!.id, + turnId: secondTurn!.id, + generationId: "server-generation-2", + origin: "system", + }); + (controller as any)._syncSuggestionsFromDocument(); + (controller as any)._updateSessionTurn(session!.id, secondTurn!.id, { + status: "review", + generationId: "server-generation-2", + suggestionIds: secondApplyResult.suggestionIds, + }); + + controller.registerExternalInlineTurnResult({ + sessionId: session!.id, + turnId: firstTurn!.id, + historyId: "server-generation-1", + operations: firstOperations, + suggestionIds: firstApplyResult.suggestionIds, + }); + controller.registerExternalInlineTurnResult({ + sessionId: session!.id, + turnId: secondTurn!.id, + historyId: "server-generation-2", + operations: secondOperations, + suggestionIds: secondApplyResult.suggestionIds, + }); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello there friend", + ); + expect((controller as any).handleInlineHistoryShortcut("undo")).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello there", + ); + expect((controller as any).handleInlineHistoryShortcut("undo")).toBe(true); + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello", + ); + }); + + it("keeps the public AI controller inline history methods available", async () => { + let turnIndex = 0; + const turnOutputs = ["planet", "galaxy"]; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; + yield { type: "done" as const }; + turnIndex += 1; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "First rewrite"); + controller.suspendInlineSession(session!.id); + await controller.runSessionPrompt(session!.id, "Second rewrite"); + controller.suspendInlineSession(session!.id); + + expect(controller.canUndoInlineHistory()).toBe(true); + expect(controller.canRedoInlineHistory()).toBe(false); + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + expect(inlineHistory.canUndoInlineHistory()).toBe(true); + expect(controller.undoInlineHistory()).toBe(true); + expect(controller.canRedoInlineHistory()).toBe(true); + expect(controller.redoInlineHistory()).toBe(true); + }); + + it("cycles selection inline turn history even when suggest mode is enabled", async () => { + let turnIndex = 0; + const turnOutputs = ["planet", "galaxy"]; + const editor = createEditor({ + extensions: [ + aiExtension({ + suggestMode: true, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; + yield { type: "done" as const }; + turnIndex += 1; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + await controller.runSessionPrompt(session!.id, "First rewrite"); + controller.suspendInlineSession(session!.id); + await controller.runSessionPrompt(session!.id, "Second rewrite"); + controller.suspendInlineSession(session!.id); + + expect(inlineHistory.canHandleShortcut("undo")).toBe(true); + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + expect(inlineHistory.handleShortcut("undo")).toBe(true); + expect(controller.getState().sessions).toHaveLength(0); + }); + + it("prefers document undo over local inline history shortcuts when both exist", () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const inlineHistory = getAIInlineHistoryController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + controller.suspendInlineSession(session!.id); + + editor.apply( + [{ type: "insert-text", blockId, offset: 11, text: "!" }], + { origin: "user" }, + ); + + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world!"); + expect(inlineHistory.canUndoInlineHistory()).toBe(true); + expect(inlineHistory.canHandleShortcut("undo")).toBe(false); + + expect(editor.undoManager.undo()).toBe(true); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); + expect(inlineHistory.canHandleShortcut("undo")).toBe(false); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part7.test.ts b/packages/extensions/ai/src/__tests__/extension.part7.test.ts new file mode 100644 index 0000000..74bbba3 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part7.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("creates a fresh inline session when the selection target changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [ + { + type: "insert-text", + blockId, + offset: 0, + text: "Hello world again", + }, + ], + { origin: "system" }, + ); + editor.selectTextRange({ blockId, offset: 6 }, { blockId, offset: 11 }); + + const controller = getAIController(editor)!; + const firstSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(firstSession).not.toBeNull(); + + await controller.runSessionPrompt( + firstSession!.id, + "Rewrite the selection", + ); + expect(controller.getState().sessions).toHaveLength(1); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 5 }); + + const secondSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(secondSession).not.toBeNull(); + expect(secondSession?.id).not.toBe(firstSession?.id); + expect(controller.getState().sessions).toHaveLength(2); + expect(controller.getState().activeSessionId).toBe(secondSession?.id); + expect(controller.getState().sessions[0]?.turns).toHaveLength(1); + expect( + controller.getState().sessions[0]?.contextualPrompt?.composer + .isOpen, + ).toBe(false); + expect(controller.getState().sessions[1]?.turns).toHaveLength(0); + expect( + controller.getState().sessions[1]?.contextualPrompt?.composer + .isOpen, + ).toBe(true); + }); + + it("opens an inline contextual prompt from a collapsed caret through auto target resolution", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange({ blockId, offset: 5 }, { blockId, offset: 5 }); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "auto", + }); + + expect(session).not.toBeNull(); + expect(session?.target).toEqual({ kind: "block", blockId }); + expect(session?.contextualPrompt?.anchor).toMatchObject({ + kind: "block", + focusBlockId: blockId, + }); + expect(session?.contextualPrompt?.composer.isOpen).toBe(true); + }); + + it("does not open inline contextual prompts for document targets", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "document", + }); + + expect(session).toBeNull(); + expect(controller.getState().sessions).toHaveLength(0); + }); + + it("keeps inline session prompts selection-scoped for follow-up edits", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange({ blockId, offset: 6 }, { blockId, offset: 11 }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Add an intro paragraph before this text", + ); + + expect(generation.target).toBe("selection"); + expect(controller.getState().sessions[0]?.turns[0]?.target).toBe( + "selection", + ); + expect(editor.documentState.blockOrder).toHaveLength(1); + }); + + it("closes the inline composer when resolving a session", async () => { + const createInlineSessionEditor = () => + createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const acceptEditor = createInlineSessionEditor(); + const acceptBlockId = acceptEditor.firstBlock()!.id; + acceptEditor.apply( + [ + { + type: "insert-text", + blockId: acceptBlockId, + offset: 0, + text: "Hello world", + }, + ], + { origin: "system" }, + ); + acceptEditor.selectTextRange( + { blockId: acceptBlockId, offset: 6 }, + { blockId: acceptBlockId, offset: 11 }, + ); + const acceptController = getAIController(acceptEditor)!; + const acceptSession = acceptController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await acceptController.runSessionPrompt( + acceptSession.id, + "Rewrite the selection", + ); + + expect( + acceptController.resolveSession(acceptSession.id, "accept"), + ).toBe(true); + expect( + acceptController.getActiveSession()?.contextualPrompt?.composer + .isOpen, + ).toBe(false); + + const rejectEditor = createInlineSessionEditor(); + const rejectBlockId = rejectEditor.firstBlock()!.id; + rejectEditor.apply( + [ + { + type: "insert-text", + blockId: rejectBlockId, + offset: 0, + text: "Hello world", + }, + ], + { origin: "system" }, + ); + rejectEditor.selectTextRange( + { blockId: rejectBlockId, offset: 6 }, + { blockId: rejectBlockId, offset: 11 }, + ); + const rejectController = getAIController(rejectEditor)!; + const rejectSession = rejectController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await rejectController.runSessionPrompt( + rejectSession.id, + "Rewrite the selection", + ); + + expect( + rejectController.resolveSession(rejectSession.id, "reject"), + ).toBe(true); + expect( + rejectController.getActiveSession()?.contextualPrompt?.composer + .isOpen, + ).toBe(false); + }); + + it("closes the inline composer when resolving a session turn", async () => { + const createInlineSessionEditor = () => + createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const acceptEditor = createInlineSessionEditor(); + const acceptBlockId = acceptEditor.firstBlock()!.id; + acceptEditor.apply( + [ + { + type: "insert-text", + blockId: acceptBlockId, + offset: 0, + text: "Hello world", + }, + ], + { origin: "system" }, + ); + acceptEditor.selectTextRange( + { blockId: acceptBlockId, offset: 6 }, + { blockId: acceptBlockId, offset: 11 }, + ); + const acceptController = getAIController(acceptEditor)!; + const acceptSession = acceptController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await acceptController.runSessionPrompt( + acceptSession.id, + "Rewrite the selection", + ); + const acceptedTurnId = + acceptController.getActiveSession()?.turns[0]?.id; + + expect( + acceptController.resolveSessionTurn( + acceptSession.id, + acceptedTurnId!, + "accept", + ), + ).toBe(true); + expect( + acceptController.getActiveSession()?.contextualPrompt?.composer + .isOpen, + ).toBe(false); + + const rejectEditor = createInlineSessionEditor(); + const rejectBlockId = rejectEditor.firstBlock()!.id; + rejectEditor.apply( + [ + { + type: "insert-text", + blockId: rejectBlockId, + offset: 0, + text: "Hello world", + }, + ], + { origin: "system" }, + ); + rejectEditor.selectTextRange( + { blockId: rejectBlockId, offset: 6 }, + { blockId: rejectBlockId, offset: 11 }, + ); + const rejectController = getAIController(rejectEditor)!; + const rejectSession = rejectController.startSession({ + surface: "inline-edit", + target: "selection", + }); + await rejectController.runSessionPrompt( + rejectSession.id, + "Rewrite the selection", + ); + const rejectedTurnId = + rejectController.getActiveSession()?.turns[0]?.id; + + expect( + rejectController.resolveSessionTurn( + rejectSession.id, + rejectedTurnId!, + "reject", + ), + ).toBe(true); + expect( + rejectController.getActiveSession()?.contextualPrompt?.composer + .isOpen, + ).toBe(false); + }); + + it("uses the captured inline session selection even if the editor selection changes", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange({ blockId, offset: 6 }, { blockId, offset: 11 }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + editor.selectTextRange({ blockId, offset: 0 }, { blockId, offset: 5 }); + + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the selection", + ); + + expect(generation.status).toBe("complete"); + expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( + "Hello planet", + ); + }); + + it("routes inline session continue prompts to block streaming suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: " More detail", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange({ blockId, offset: 6 }, { blockId, offset: 11 }); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Continue this paragraph", + ); + + expect(generation.target).toBe("selection"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(editor.getBlock(blockId)!.textContent()).toContain( + "Hello world", + ); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part8.test.ts b/packages/extensions/ai/src/__tests__/extension.part8.test.ts new file mode 100644 index 0000000..2722cb2 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part8.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("routes inline local-edit prompts to block streaming suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " Better version" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + + const generation = await controller.runSessionPrompt( + session.id, + "Make it better", + ); + + expect(generation.target).toBe("selection"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); + + it("uses the live collapsed caret offset for block generations", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " AI" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 5 }, + { blockId, offset: 5 }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Continue this paragraph", { + target: "block", + blockId, + }); + + expect(generation.target).toBe("block"); + const suggestions = controller.getSuggestions(); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions[0]).toMatchObject({ + blockId, + offset: 5, + }); + }); + + it("uses the selection end as the insertion offset for inline block turns", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " Better" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Make it better", + ); + + expect(generation.target).toBe("selection"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + const suggestions = controller.getSuggestions(); + expect(suggestions.length).toBeGreaterThan(0); + expect(suggestions[0]?.blockId).toBe(blockId); + }); + + it("creates reviewable cross-block inline edit suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "X" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const firstBlockId = editor.firstBlock()!.id; + editor.apply([ + { + type: "insert-block", + blockId: "b2", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: "b3", + blockType: "paragraph", + props: {}, + position: "last", + }, + { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, + { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, + { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, + ]); + editor.selectTextRange( + { blockId: firstBlockId, offset: 2 }, + { blockId: "b3", offset: 2 }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "inline-edit", + target: "selection", + }); + const generation = await controller.runSessionPrompt( + session.id, + "Rewrite the selection", + ); + const nextSession = controller.getActiveSession(); + const turn = nextSession?.turns[0]; + + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + expect(turn?.selection?.isMultiBlock).toBe(true); + expect(turn?.status).toBe("review"); + expect(controller.acceptSessionTurn(session.id, turn!.id)).toBe(true); + expect(editor.getBlock(firstBlockId)?.textContent({ resolved: true })).toBe("HeXain"); + expect(editor.getBlock("b2")).toBeNull(); + expect(editor.getBlock("b3")).toBeNull(); + }); + + it("records progressive tool stream events for the active generation", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + pass += 1; + if (pass === 1) { + yield { + type: "tool-call" as const, + toolCallId: "tool-call-1", + toolName: "test_search", + input: { query: "plan" }, + }; + } + yield { type: "done" as const }; + }, + }, + }), + testStreamingToolExtension(), + ], + }); + const controller = getAIController(editor)!; + const blockId = editor.firstBlock()!.id; + + const generation = await controller.runPrompt("search the document", { blockId }); + const streamEvents = controller.getStreamEvents(); + const streamEventTypes = streamEvents.map((event) => event.type); + const toolOutputEvents = streamEvents.filter( + (event) => event.type === "tool-output", + ); + const toolResultEvent = streamEvents.find( + (event) => event.type === "tool-result", + ); + + expect(generation.status).toBe("complete"); + expect(streamEventTypes).toEqual([ + "generation-start", + "status", + "tool-call", + "status", + "tool-output", + "tool-output", + "tool-result", + "status", + "generation-finish", + ]); + expect(toolOutputEvents).toHaveLength(2); + expect(toolOutputEvents[0]).toMatchObject({ + toolCallId: "tool-call-1", + toolName: "test_search", + part: "searching:plan", + output: "searching:plan", + }); + expect(toolOutputEvents[1]).toMatchObject({ + toolCallId: "tool-call-1", + toolName: "test_search", + part: { matches: 2, query: "plan" }, + output: ["searching:plan", { matches: 2, query: "plan" }], + }); + expect(toolResultEvent).toMatchObject({ + type: "tool-result", + toolCallId: "tool-call-1", + toolName: "test_search", + output: ["searching:plan", { matches: 2, query: "plan" }], + state: "complete", + }); + }); + + it("streams block structured previews before a block plan finishes", async () => { + const releaseSecondDelta = createDeferred(); + let streamedBlockId = ""; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: + `{"kind":"block_convert","blockId":"${streamedBlockId}","newType":"heading"`, + }; + await releaseSecondDelta.promise; + yield { + type: "text-delta" as const, + delta: ',"props":{"level":2}}', + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + streamedBlockId = blockId; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generationPromise = controller.runPrompt("Convert block to heading", { + blockId, + }); + await waitForPreview( + () => controller.getState().activeGeneration?.structuredPreview, + ); + + const activeGeneration = controller.getState().activeGeneration; + const previewEventsBeforeCompletion = controller.getStreamEvents().filter( + (event) => event.type === "structured-preview", + ); + expect(activeGeneration?.structuredPreview).toMatchObject({ + planState: "drafted", + plan: { + kind: "block_convert", + blockId, + newType: "heading", + }, + }); + expect(activeGeneration?.structuredPreview?.reviewItems).toEqual([ + expect.objectContaining({ + label: "Convert block", + section: "block", + changeKind: "updated", + }), + ]); + expect(controller.getStreamEvents().some((event) => ( + event.type === "structured-preview" && + event.preview.plan.kind === "block_convert" + ))).toBe(true); + expect(previewEventsBeforeCompletion).toHaveLength(1); + expect(previewEventsBeforeCompletion[0]).toMatchObject({ + patches: [ + { op: "add", path: "/planState", value: "drafted" }, + { op: "add", path: "/plan", value: expect.any(Object) }, + { op: "add", path: "/reviewItems", value: expect.any(Array) }, + { op: "add", path: "/targets", value: [] }, + ], + }); + + releaseSecondDelta.resolve(); + const generation = await generationPromise; + const previewEventsAfterCompletion = controller.getStreamEvents().filter( + (event) => event.type === "structured-preview", + ); + const finalPreviewEvent = + previewEventsAfterCompletion[previewEventsAfterCompletion.length - 1]; + expect(generation.structuredPreview).toMatchObject({ + planState: "validated", + plan: { + kind: "block_convert", + blockId, + newType: "heading", + props: { level: 2 }, + }, + }); + expect(finalPreviewEvent).toMatchObject({ + patches: [ + { op: "replace", path: "/planState", value: "validated" }, + { op: "add", path: "/plan/props", value: {} }, + { op: "add", path: "/plan/props/level", value: 2 }, + ], + }); + expect( + finalPreviewEvent?.patches.some((patch) => patch.path === "/plan"), + ).toBe(false); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.part9.test.ts b/packages/extensions/ai/src/__tests__/extension.part9.test.ts new file mode 100644 index 0000000..fae4a5f --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.part9.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { createEditor } from "@pen/core"; +import { + acceptAllSuggestions, + acceptSuggestion, + aiExtension, + getAIInlineHistoryController, + getAIController, + rejectSuggestion, +} from "../index"; +import { + readAllSuggestions, + readBlockSuggestionMeta, + readSuggestionsFromBlock, +} from "../suggestions/persistent"; +import { + createDeferred, + testStreamingToolExtension, + waitForPreview, +} from "./extension.testUtils"; + +describe("aiExtension", () => { + it("keeps selection rewrites text-only when markdown block generation is enabled", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { blockGeneration: "markdown" }, + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "# Planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Rewrite the selection"); + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("text"); + expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world# Planet"); + expect(editor.documentState.blockOrder).toHaveLength(1); + }); + + it("routes context-first block edits into persistent suggestions", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: " Updated" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const generation = await controller.runPrompt("Improve this paragraph", { blockId }); + const block = editor.getBlock(blockId)!; + + expect(generation.route).toBe("context-first"); + expect(generation.mutationMode).toBe("persistent-suggestions"); + expect(block.textContent()).toBe("Hello Updated"); + expect(controller.getSuggestions().length).toBeGreaterThan(0); + }); + + it("uses markdown block generation for bottom-chat document writing", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "Once upon " }; + await releaseFinalDelta.promise; + yield { type: "text-delta" as const, delta: "a time" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], + { origin: "system" }, + ); + + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generationPromise = controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + + await waitForPreview(() => { + const activeGeneration = controller.getState().activeGeneration; + const streamedVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + return ( + activeGeneration?.surface === "bottom-chat" && + activeGeneration.contentFormat === "markdown" && + streamedVisibleBlockTexts.includes("Once upon") + ); + }); + + const streamedVisibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + + expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); + expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); + expect(controller.getState().activeGeneration?.mutationMode).toBe( + "streaming-suggestions", + ); + expect(streamedVisibleBlockTexts).toEqual(["Hello", "Once upon"]); + expect(session.surface).toBe("bottom-chat"); + + releaseFinalDelta.resolve(); + const generation = await generationPromise; + const visibleBlockTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(generation.status).toBe("complete"); + expect(generation.mutationMode).toBe("streaming-suggestions"); + expect(generation.contentFormat).toBe("markdown"); + expect(generation.adapterId).toBe("flow-markdown"); + expect(generation.blockClass).toBe("flow"); + expect(generation.transportKind).toBe("flow-text"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + expect(visibleBlockTexts).toEqual(["Hello", "Once upon a time"]); + }); + + it("streams bottom-chat markdown as block suggestions before completion", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream(options) { + yield { + type: "replace-preview" as const, + operation: options.operation!, + text: "\n\nOnce upon ", + }; + await releaseFinalDelta.promise; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: "\n\nOnce upon a time", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + const controller = getAIController(editor)!; + const session = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + const generationPromise = controller.runSessionPrompt( + session.id, + "Write a short story", + { target: "document" }, + ); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); + expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); + const visibleStreamingTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect( + (editor.getBlock(blockId)?.textContent({ resolved: true }) ?? "").replace( + /^\u200b/, + "", + ), + ).toBe(""); + expect(visibleStreamingTexts).toEqual(["Once upon"]); + + releaseFinalDelta.resolve(); + const generation = await generationPromise; + + expect(generation.status).toBe("complete"); + expect(generation.contentFormat).toBe("markdown"); + expect(generation.text).toBe("\n\nOnce upon a time"); + expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); + expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); + const visibleFinalTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(visibleFinalTexts).toEqual(["Once upon a time"]); + const turnId = controller + .getState() + .sessions.find((item) => item.id === session.id) + ?.turns[0]?.id; + expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); + const keptTexts = editor.documentState.blockOrder + .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") + .filter((text) => text.trim().length > 0); + expect(keptTexts).toEqual(["Once upon a time"]); + }); + + it("allows inline selection edits after keeping bottom-chat changes", async () => { + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream(options) { + pass += 1; + yield { + type: "replace-final" as const, + operation: options.operation!, + text: pass === 1 ? "Hello world" : "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + + const controller = getAIController(editor)!; + const bottomChatSession = controller.startSession({ + surface: "bottom-chat", + target: "document", + }); + await controller.runSessionPrompt( + bottomChatSession.id, + "Write something in the document", + { target: "document" }, + ); + + const keptTurnId = controller + .getSessions() + .find((session) => session.id === bottomChatSession.id) + ?.turns[0]?.id; + expect(keptTurnId).toBeTruthy(); + expect(controller.acceptSessionTurn(bottomChatSession.id, keptTurnId!)).toBe(true); + + const blockId = editor.firstBlock()!.id; + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const inlineSession = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(inlineSession).not.toBeNull(); + + const generation = await controller.runSessionPrompt( + inlineSession!.id, + "Rewrite the selection", + { target: "selection" }, + ); + + expect(generation.target).toBe("selection"); + expect( + controller + .getSessions() + .find((session) => session.id === inlineSession!.id) + ?.turns, + ).toHaveLength(1); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/extension.test.ts b/packages/extensions/ai/src/__tests__/extension.test.ts index b590a28..6b8ec35 100644 --- a/packages/extensions/ai/src/__tests__/extension.test.ts +++ b/packages/extensions/ai/src/__tests__/extension.test.ts @@ -1,4845 +1,3 @@ -import { describe, expect, it } from "vitest"; -import { createEditor } from "@pen/core"; -import { - defineExtension, - type ToolRuntime, -} from "@pen/types"; -import { - acceptAllSuggestions, - acceptSuggestion, - aiExtension, - getAIInlineHistoryController, - getAIController, - rejectSuggestion, -} from "../index"; -import { - readAllSuggestions, - readBlockSuggestionMeta, - readSuggestionsFromBlock, -} from "../suggestions/persistent"; +import { describe } from "vitest"; -function testStreamingToolExtension() { - let toolRuntime: ToolRuntime | null = null; - - return defineExtension({ - name: "test-streaming-tool", - dependencies: ["document-ops"], - activateClient: async ({ editor }) => { - toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; - toolRuntime?.registerTool({ - name: "test_search", - description: "Test streaming search tool", - inputSchema: { - type: "object", - required: ["query"], - properties: { - query: { type: "string" }, - }, - }, - async *handler(input) { - const { query } = input as { query: string }; - yield `searching:${query}`; - yield { matches: 2, query }; - }, - }); - }, - deactivateClient: async () => { - toolRuntime?.unregisterTool("test_search"); - toolRuntime = null; - }, - }); -} - -function createDeferred() { - let resolve!: () => void; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - return { promise, resolve }; -} - -async function waitForPreview( - readPreview: () => unknown, - maxTicks = 10, -): Promise { - for (let tick = 0; tick < maxTicks; tick += 1) { - if (readPreview()) { - return; - } - await Promise.resolve(); - } -} - -describe("aiExtension", () => { - it("marks inserted and deleted text in suggest mode", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "user" }, - ); - editor.apply( - [{ type: "delete-text", blockId, offset: 6, length: 5 }], - { origin: "user" }, - ); - - const block = editor.getBlock(blockId)!; - const deltas = block.textDeltas(); - - expect(deltas[0]?.attributes?.suggestion).toMatchObject({ - action: "insert", - author: "tester", - }); - expect(deltas[1]?.attributes?.suggestion).toMatchObject({ - action: "delete", - author: "tester", - }); - expect(block.textContent()).toBe("Hello world"); - expect(block.textContent({ resolved: true })).toBe("Hello "); - }); - - it("rejects persistent suggestions through the controller", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "user" }, - ); - - const controller = getAIController(editor)!; - const suggestionsSnapshot = controller.getSuggestions(); - const suggestion = suggestionsSnapshot[0]; - expect(suggestion).toBeDefined(); - expect(controller.getSuggestions()).toBe(suggestionsSnapshot); - - expect(rejectSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - expect(readAllSuggestions(editor)).toEqual([]); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - }); - - it("accepts persistent suggestions without re-intercepting them", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - editor.apply( - [{ type: "delete-text", blockId, offset: 0, length: 5 }], - { origin: "user" }, - ); - - const [suggestion] = readSuggestionsFromBlock(editor, blockId); - expect(suggestion).toBeDefined(); - - expect(acceptSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - expect(readAllSuggestions(editor)).toEqual([]); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - }); - - it("keeps accepted delete suggestions in document undo history", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - editor.apply( - [{ type: "delete-text", blockId, offset: 0, length: 5 }], - { origin: "user" }, - ); - - const [suggestion] = readSuggestionsFromBlock(editor, blockId); - expect(suggestion).toBeDefined(); - expect(acceptSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello"); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.redo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - }); - - it("keeps rejected insert suggestions in document undo history", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const blockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "user" }, - ); - - const [suggestion] = readSuggestionsFromBlock(editor, blockId); - expect(suggestion).toBeDefined(); - expect(rejectSuggestion(editor, suggestion.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - - expect(editor.undoManager.redo()).toBe(true); - expect(readSuggestionsFromBlock(editor, blockId)).toHaveLength(1); - expect(readAllSuggestions(editor)).toHaveLength(1); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe(""); - expect(readSuggestionsFromBlock(editor, blockId)).toEqual([]); - }); - - it("accepts multiple suggestions in one undo group", () => { - const editor = createEditor({ - extensions: [aiExtension({ suggestMode: true, author: "tester" })], - }); - const firstBlockId = editor.firstBlock()!.id; - - editor.apply( - [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], - { origin: "user" }, - ); - editor.apply( - [ - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - ], - { origin: "user" }, - ); - - expect(readAllSuggestions(editor)).toHaveLength(2); - - acceptAllSuggestions(editor); - expect(readAllSuggestions(editor)).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(readAllSuggestions(editor)).toHaveLength(2); - - expect(editor.undoManager.redo()).toBe(true); - expect(readAllSuggestions(editor)).toEqual([]); - }); - - it("runs a block generation with a model adapter", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " world" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Continue", { blockId }); - - expect(generation.status).toBe("complete"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); - expect(controller.getState().activeGeneration?.text).toBe(" world"); - }); - - it("parses markdown block generations into structured blocks", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { blockGeneration: "markdown" }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "# Title\n\n- One", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const targetBlockId = "target-block"; - const trailingBlockId = "trailing-block"; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: targetBlockId, - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-block", - blockId: trailingBlockId, - blockType: "paragraph", - props: {}, - position: { after: targetBlockId }, - }, - { - type: "insert-text", - blockId: trailingBlockId, - offset: 0, - text: "Outro", - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")?.tableRowCount(); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Continue this paragraph", { - blockId: targetBlockId, - }); - const blockOrder = editor.documentState.blockOrder; - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("markdown"); - expect(blockOrder).toHaveLength(4); - expect(blockOrder).not.toContain(targetBlockId); - expect(editor.getBlock(blockOrder[0])?.textContent()).toBe("Intro"); - expect(editor.getBlock(blockOrder[1])?.type).toBe("heading"); - expect(editor.getBlock(blockOrder[1])?.textContent()).toBe("Title"); - expect(editor.getBlock(blockOrder[2])?.type).toBe("bulletListItem"); - expect(editor.getBlock(blockOrder[2])?.textContent()).toBe("One"); - expect(editor.getBlock(blockOrder[3])?.textContent()).toBe("Outro"); - }); - - it("runs a selection generation when text is selected", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Rewrite the selection"); - - expect(generation.status).toBe("complete"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe("Hello planet"); - expect(controller.getState().activeGeneration?.text).toBe("planet"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("uses selection-fast request mode for bottom-chat selection rewrites", async () => { - let requestMode: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream(options) { - requestMode = options.requestMode; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "selection", - }); - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - - expect(requestMode).toBe("selection-fast"); - }); - - it("keeps document-targeted bottom-chat rewrites off selection-fast even with a live selection", async () => { - let requestMode: string | undefined; - let operationKind: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - requestMode = options.requestMode; - operationKind = options.operation?.kind; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(requestMode).toBe("selection-fast"); - expect(operationKind).toBe("rewrite-selection"); - }); - - it("routes bottom-chat block rewrites through typed local replace operations", async () => { - let requestMode: string | undefined; - let operationKind: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - requestMode = options.requestMode; - operationKind = options.operation?.kind; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "Hello planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(requestMode).toBe("selection-fast"); - expect(operationKind).toBe("rewrite-selection"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("routes whole-document rewrites through typed local replace operations", async () => { - let operation: - | { - kind?: string; - target?: { - kind?: string; - blockIds?: readonly string[]; - contentFormat?: string; - scope?: string; - }; - } - | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - operation = options.operation; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Cat Keeper\n\nA cat story.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper" }, - { type: "convert-block", blockId, newType: "heading" }, - { - type: "insert-block", - blockId: "paragraph-1", - blockType: "paragraph", - props: {}, - position: { after: blockId }, - }, - { - type: "insert-text", - blockId: "paragraph-1", - offset: 0, - text: "A lighthouse story.", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the whole story. Make it about cats.", - { target: "document" }, - ); - - expect(operation?.kind).toBe("rewrite-selection"); - expect(operation?.target?.kind).toBe("scoped-range"); - expect(operation?.target?.contentFormat).toBe("markdown"); - expect(operation?.target?.scope).toBe("document"); - expect(operation?.target?.blockIds).toContain("paragraph-1"); - expect(operation?.target?.blockIds).toHaveLength(2); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleBlockTexts).toEqual(["The Cat Keeper", "A cat story."]); - }); - - it("routes remove-all document edits through typed local delete suggestions", async () => { - let operation: - | { - kind?: string; - target?: { - kind?: string; - blockIds?: readonly string[]; - contentFormat?: string; - scope?: string; - }; - } - | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - operation = options.operation; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Remove all content in the document.", - { target: "document" }, - ); - - expect(operation).toMatchObject({ - kind: "rewrite-selection", - target: { - kind: "scoped-range", - blockIds: editor.documentState.blockOrder, - contentFormat: "markdown", - scope: "document", - }, - }); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleBlockTexts).toEqual([]); - }); - - it("keeps heading rewrites block-bounded instead of inserting a new markdown block", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Keeper's Final Watch", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId, offset: 0, text: "The Lighthouse Keeper's Last Night" }, - { type: "convert-block", blockId, newType: "heading" }, - ], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 0 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - expect(editor.firstBlock()?.type).toBe("heading"); - expect(editor.firstBlock()!.textContent({ resolved: true })).toBe( - "The Keeper's Final Watch", - ); - expect(editor.documentState.blockOrder).toHaveLength(1); - }); - - it("routes bottom-chat continue prompts to typed insert operations", async () => { - let operationKind: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - operationKind = options.operation?.kind; - yield { - type: "insert-final" as const, - operation: options.operation!, - text: " and beyond", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - await controller.runSessionPrompt(session.id, "Continue writing"); - - expect(operationKind).toBe("continue-block"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello world and beyond", - ); - }); - - it("falls back to document review mode for bottom-chat rewrites on non-text blocks", async () => { - let requestMode: string | undefined; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - requestMode = options.requestMode; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId, offset: 0, text: "Hello table" }, - { type: "convert-block", blockId, newType: "table", newProps: {} }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - - expect(requestMode).toBe("selection-fast"); - expect(generation.route).toBe("selection-rewrite"); - expect(generation.mutationReceipt?.status).toBe("noop"); - expect(editor.getBlock(blockId)?.type).toBe("table"); - }); - - it("marks local bottom-chat rewrites invalid when target provenance changes", async () => { - const releaseFinalFrame = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - yield { - type: "replace-preview" as const, - operation: options.operation!, - text: "Hello planet", - }; - await releaseFinalFrame.promise; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "Hello planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generationPromise = controller.runSessionPrompt(session.id, "Rewrite this"); - for (let tick = 0; tick < 4; tick += 1) { - await Promise.resolve(); - } - editor.apply( - [{ type: "insert-text", blockId, offset: 11, text: "!" }], - { origin: "user" }, - ); - releaseFinalFrame.resolve(); - const generation = await generationPromise; - - expect(generation.mutationReceipt?.status).toBe("invalid"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello world!", - ); - }); - - it("accepts typed local bottom-chat document rewrites", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "text", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# Hello planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - }); - const generation = await controller.runSessionPrompt(session.id, "Rewrite this"); - expect(generation.status).toBe("complete"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - }); - - it("streams selection rewrites into persistent suggestions before completion", async () => { - const releaseSecondDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "plan" }; - await releaseSecondDelta.promise; - yield { type: "text-delta" as const, delta: "et" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const generationPromise = controller.runPrompt("Rewrite the selection"); - for (let tick = 0; tick < 6; tick += 1) { - await Promise.resolve(); - } - - expect(controller.getState().ephemeralSuggestion).toBeNull(); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplan"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - - releaseSecondDelta.resolve(); - const generation = await generationPromise; - - expect(generation.status).toBe("complete"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello worldplanet"); - }); - - it("tracks session prompts and accepts session suggestions together", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the selection", - ); - const nextSession = controller.getActiveSession(); - - expect(generation.sessionId).toBe(session.id); - expect(nextSession?.promptHistory).toHaveLength(1); - expect(nextSession?.turns).toHaveLength(1); - expect(nextSession?.turns[0]?.generationId).toBe(generation.id); - expect(nextSession?.turns[0]?.status).toBe("review"); - expect(nextSession?.generationIds).toContain(generation.id); - expect(nextSession?.pendingSuggestionIds.length).toBeGreaterThan(0); - expect(controller.acceptSessionTurn(session.id, nextSession!.turns[0]!.id)).toBe(true); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("includes prior inline prompts when continuing the same inline edit session", async () => { - const capturedPrompts: string[] = []; - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream(options) { - capturedPrompts.push(String(options.messages[0]?.content ?? "")); - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "forest", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 12 }, - ); - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - expect(capturedPrompts[1]).toContain( - "You are continuing an existing inline editor edit session.", - ); - expect(capturedPrompts[1]).toContain( - "Earlier user requests in this same session:", - ); - expect(capturedPrompts[1]).toContain("1. Rewrite the selection"); - expect(capturedPrompts[1]).toContain( - "Latest request:\nMake it more whimsical", - ); - }); - - it("refreshes the inline follow-up target after accepting a rewritten selection", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream(options) { - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - const secondOperation = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.operation; - expect(secondOperation?.kind).toBe("rewrite-selection"); - expect(secondOperation?.target.kind).toBe("selection"); - if (secondOperation?.target.kind !== "selection") { - throw new Error("Expected selection target for inline follow-up"); - } - expect(secondOperation.target.sourceText).toBe("planet"); - expect( - secondOperation.target.focus.offset - secondOperation.target.anchor.offset, - ).toBeGreaterThanOrEqual("planet".length); - }); - - it("refreshes the inline follow-up target after keeping a rewritten selection", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - expect(controller.acceptActiveGeneration()).toBe(true); - - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - const secondOperation = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.operation; - expect(secondOperation?.kind).toBe("rewrite-selection"); - expect(secondOperation?.target.kind).toBe("selection"); - if (secondOperation?.target.kind !== "selection") { - throw new Error("Expected selection target for kept inline follow-up"); - } - expect(secondOperation.target.sourceText).toBe("planet"); - expect( - secondOperation.target.focus.offset - secondOperation.target.anchor.offset, - ).toBeGreaterThanOrEqual("planet".length); - }); - - it("refreshes the inline follow-up target while the prior turn is still in review", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - streamCount += 1; - yield { - type: "text-delta" as const, - delta: streamCount === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - expect( - controller.getSessions().find((item) => item.id === session.id)?.turns[0]?.status, - ).toBe("review"); - - await controller.runSessionPrompt(session.id, "Make it more whimsical"); - - const secondOperation = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.operation; - expect(secondOperation?.kind).toBe("rewrite-selection"); - expect(secondOperation?.target.kind).toBe("selection"); - if (secondOperation?.target.kind !== "selection") { - throw new Error("Expected selection target for inline follow-up review"); - } - expect(secondOperation.target.sourceText).toBe("planet"); - expect( - secondOperation.target.focus.offset - secondOperation.target.anchor.offset, - ).toBeGreaterThanOrEqual("planet".length); - }); - - it("keeps inline prompt targets stable after the live selection changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - editor.selectTextRange( - { blockId, offset: 11 }, - { blockId, offset: 11 }, - ); - - const originalSelectTextRange = editor.selectTextRange.bind(editor); - editor.selectTextRange = () => { - // Simulate a selection target that can no longer be reselected from live state. - }; - - try { - const generation = await controller.runSessionPrompt( - session!.id, - "Rewrite the selection", - ); - - expect(generation.status).toBe("complete"); - expect(generation.target).toBe("selection"); - expect( - controller - .getSessions() - .find((item) => item.id === session!.id) - ?.turns[0]?.target, - ).toBe("selection"); - } finally { - editor.selectTextRange = originalSelectTextRange; - } - }); - - it("restores inline edit review state through document undo and redo", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - - const acceptedSession = controller.getActiveSession(); - expect(acceptedSession?.contextualPrompt?.composer.isOpen).toBe(false); - expect(acceptedSession?.turns[0]?.status).toBe("accepted"); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello planet", - ); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 0 }, - ); - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.turns).toHaveLength(1); - expect(restoredSession?.turns[0]?.status).toBe("review"); - expect(restoredSession?.turns[0]?.suggestionIds.length ?? 0).toBeGreaterThan(0); - - expect(editor.undoManager.redo()).toBe(true); - - const redoneSession = controller.getActiveSession(); - expect(redoneSession?.id).toBe(session!.id); - expect(redoneSession?.contextualPrompt?.composer.isOpen).toBe(false); - expect(redoneSession?.turns[0]?.status).toBe("accepted"); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("lets inline edit continue from an undone review state", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - pass += 1; - yield { - type: "text-delta" as const, - delta: pass === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - - await controller.runSessionPrompt(session!.id, "Try another rewrite"); - - const resumedSession = controller.getActiveSession(); - expect(resumedSession?.turns).toHaveLength(2); - expect(resumedSession?.turns[1]?.prompt).toBe("Try another rewrite"); - expect(resumedSession?.turns[1]?.status).toBe("review"); - }); - - it("undoes a streamed inline turn as one step even when deltas span capture timeouts", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "pla" }; - await new Promise((resolve) => setTimeout(resolve, 550)); - yield { type: "text-delta" as const, delta: "net" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - - expect(editor.undoManager.undo()).toBe(true); - const restoredReviewSession = controller.getActiveSession(); - expect(restoredReviewSession?.id).toBe(session!.id); - expect(restoredReviewSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredReviewSession?.turns).toHaveLength(1); - expect(restoredReviewSession?.turns[0]?.status).toBe("review"); - - expect(editor.undoManager.undo()).toBe(true); - const restoredPromptSession = controller.getActiveSession(); - expect(restoredPromptSession?.id).toBe(session!.id); - expect(restoredPromptSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredPromptSession?.turns).toHaveLength(0); - expect(restoredPromptSession?.contextualPrompt?.composer.draftPrompt).toBe( - "Rewrite the selection", - ); - - expect(editor.undoManager.redo()).toBe(true); - const redoneReviewSession = controller.getActiveSession(); - expect(redoneReviewSession?.turns).toHaveLength(1); - expect(redoneReviewSession?.turns[0]?.status).toBe("review"); - - expect(editor.undoManager.redo()).toBe(true); - const redoneAcceptedSession = controller.getActiveSession(); - expect(redoneAcceptedSession?.turns[0]?.status).toBe("accepted"); - }); - - it("does not reopen accepted inline review for unrelated undo operations", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - const secondBlockId = crypto.randomUUID(); - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello world" }, - { - type: "insert-block", - blockId: secondBlockId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: secondBlockId, offset: 0, text: "Other block" }, - ], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId: firstBlockId, offset: 6 }, - { blockId: firstBlockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( - false, - ); - editor.undoManager.stopCapturing(); - - editor.selectText(secondBlockId, 11, 11); - editor.apply( - [{ type: "insert-text", blockId: secondBlockId, offset: 11, text: "!" }], - { origin: "user" }, - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(secondBlockId)?.textContent()).toBe("Other block"); - expect(controller.getActiveSession()?.id).toBe(session!.id); - expect(controller.getActiveSession()?.contextualPrompt?.composer.isOpen).toBe( - false, - ); - expect(controller.getActiveSession()?.turns[0]?.status).toBe("accepted"); - }); - - it("restores the latest inline review turn even when no inline session is active", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - controller.suspendInlineSession(session!.id); - expect(controller.getState().activeSessionId).toBeNull(); - - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.turns[0]?.status).toBe("review"); - }); - - it("restores the inline prompt in the same undo step after accepting a suspended review turn", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - - controller.suspendInlineSession(session!.id); - expect(controller.getState().activeSessionId).toBeNull(); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - expect(editor.undoManager.undo()).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( - "Rewrite the selection", - ); - expect(restoredSession?.turns).toHaveLength(1); - expect(restoredSession?.turns[0]?.status).toBe("review"); - }); - - it("restores prompt and review state on the first inline history undo shortcut", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "Rewrite the selection"); - const reviewTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(reviewTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session!.id, reviewTurnId!)).toBe(true); - - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - expect(inlineHistory.handleShortcut("undo")).toBe(true); - - const restoredSession = controller.getActiveSession(); - expect(restoredSession?.id).toBe(session!.id); - expect(restoredSession?.contextualPrompt?.composer.isOpen).toBe(true); - expect(restoredSession?.contextualPrompt?.composer.draftPrompt).toBe( - "Rewrite the selection", - ); - expect(restoredSession?.turns).toHaveLength(1); - expect(restoredSession?.turns[0]?.status).toBe("review"); - }); - - it("rewrites text that was previously accepted from AI", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - pass += 1; - yield { - type: "text-delta" as const, - delta: pass === 1 ? "planet" : "galaxy", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const firstSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(firstSession).not.toBeNull(); - - await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); - const firstTurnId = controller.getActiveSession()?.turns[0]?.id; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(firstSession!.id, firstTurnId!)).toBe(true); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello planet", - ); - - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 12 }, - ); - const secondSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(secondSession).not.toBeNull(); - expect(secondSession?.id).not.toBe(firstSession?.id); - - await controller.runSessionPrompt(secondSession!.id, "Rewrite the selection"); - const secondTurnId = controller.getActiveSession()?.turns.at(-1)?.id; - expect(secondTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(secondSession!.id, secondTurnId!)).toBe(true); - expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( - "Hello galaxy", - ); - }); - - it("records selection rewrites in session fast-apply metrics", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - await controller.runSessionPrompt(session.id, "Rewrite the selection"); - - expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ - attemptCount: 1, - nativeFastApplyCount: 1, - scopedReplacementCount: 0, - plainMarkdownCount: 0, - failedCount: 0, - }); - }); - - it("accumulates fast-apply outcome counters across session turns", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ contentFormat: { blockGeneration: "markdown" } }), - ], - }); - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "block", - }); - const controllerAny = controller as any; - - controllerAny._recordSessionFastApplyMetrics(session.id, { - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - }); - controllerAny._recordSessionFastApplyMetrics(session.id, { - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - }); - controllerAny._recordSessionFastApplyMetrics(session.id, { - attempted: true, - succeeded: false, - executionPath: "plain-markdown", - fallbackReason: "unparseable-contract", - }); - - expect(controller.getActiveSession()?.metrics.fastApply).toEqual({ - attemptCount: 3, - nativeFastApplyCount: 1, - scopedReplacementCount: 1, - plainMarkdownCount: 1, - failedCount: 0, - }); - }); - - it("only restores a suspended inline session through history", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - - expect(session).not.toBeNull(); - controller.suspendInlineSession(session!.id); - expect(controller.getState().activeSessionId).toBeNull(); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 5 }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - expect(controller.getState().activeSessionId).toBeNull(); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - editor.internals.emit("historyApplied", { - kind: "undo", - selection: editor.selection, - focusBlockId: blockId, - requestId: 1, - }); - - expect(controller.getState().activeSessionId).toBe(session!.id); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(true); - - controller.suspendInlineSession(session!.id); - editor.internals.emit("historyApplied", { - kind: "redo", - selection: editor.selection, - focusBlockId: blockId, - requestId: 2, - }); - - expect(controller.getState().activeSessionId).toBe(session!.id); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(true); - }); - - it("records inline history at settled turn checkpoints instead of stream chunks", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "pla" }; - yield { type: "text-delta" as const, delta: "net" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - const inlineHistory = getAIInlineHistoryController(editor)!; - - await controller.runSessionPrompt(session!.id, "Rewrite this"); - controller.suspendInlineSession(session!.id); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - expect(controller.getState().sessions[0]?.turns[0]?.status).toBe("review"); - - expect(inlineHistory.undoInlineHistory()).toBe(true); - expect(controller.getState().activeSessionId).toBe(session!.id); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen, - ).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.undoInlineHistory()).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(0); - expect( - controller.getState().sessions[0]?.contextualPrompt?.composer.draftPrompt, - ).toBe("Rewrite this"); - }); - - it("cycles selection inline turn history one turn at a time through shortcuts", async () => { - let turnIndex = 0; - const turnOutputs = ["planet", "galaxy"]; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; - yield { type: "done" as const }; - turnIndex += 1; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "First rewrite"); - controller.suspendInlineSession(session!.id); - await controller.runSessionPrompt(session!.id, "Second rewrite"); - controller.suspendInlineSession(session!.id); - - expect(controller.getState().sessions[0]?.turns).toHaveLength(2); - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions).toHaveLength(0); - expect(controller.getState().activeSessionId).toBeNull(); - - expect(inlineHistory.canHandleShortcut("redo")).toBe(true); - expect(inlineHistory.handleShortcut("redo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.handleShortcut("redo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(2); - }); - - it("keeps the public AI controller inline history methods available", async () => { - let turnIndex = 0; - const turnOutputs = ["planet", "galaxy"]; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; - yield { type: "done" as const }; - turnIndex += 1; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "First rewrite"); - controller.suspendInlineSession(session!.id); - await controller.runSessionPrompt(session!.id, "Second rewrite"); - controller.suspendInlineSession(session!.id); - - expect(controller.canUndoInlineHistory()).toBe(true); - expect(controller.canRedoInlineHistory()).toBe(false); - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - expect(inlineHistory.canUndoInlineHistory()).toBe(true); - expect(controller.undoInlineHistory()).toBe(true); - expect(controller.canRedoInlineHistory()).toBe(true); - expect(controller.redoInlineHistory()).toBe(true); - }); - - it("cycles selection inline turn history even when suggest mode is enabled", async () => { - let turnIndex = 0; - const turnOutputs = ["planet", "galaxy"]; - const editor = createEditor({ - extensions: [ - aiExtension({ - suggestMode: true, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: turnOutputs[turnIndex] ?? "done" }; - yield { type: "done" as const }; - turnIndex += 1; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - - await controller.runSessionPrompt(session!.id, "First rewrite"); - controller.suspendInlineSession(session!.id); - await controller.runSessionPrompt(session!.id, "Second rewrite"); - controller.suspendInlineSession(session!.id); - - expect(inlineHistory.canHandleShortcut("undo")).toBe(true); - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - expect(inlineHistory.handleShortcut("undo")).toBe(true); - expect(controller.getState().sessions).toHaveLength(0); - }); - - it("prefers document undo over local inline history shortcuts when both exist", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const inlineHistory = getAIInlineHistoryController(editor)!; - const session = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(session).not.toBeNull(); - controller.suspendInlineSession(session!.id); - - editor.apply( - [{ type: "insert-text", blockId, offset: 11, text: "!" }], - { origin: "user" }, - ); - - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world!"); - expect(inlineHistory.canUndoInlineHistory()).toBe(true); - expect(inlineHistory.canHandleShortcut("undo")).toBe(false); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world"); - expect(inlineHistory.canHandleShortcut("undo")).toBe(false); - }); - - it("creates a fresh inline session when the selection target changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world again" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const firstSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(firstSession).not.toBeNull(); - - await controller.runSessionPrompt(firstSession!.id, "Rewrite the selection"); - expect(controller.getState().sessions).toHaveLength(1); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 5 }, - ); - - const secondSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(secondSession).not.toBeNull(); - expect(secondSession?.id).not.toBe(firstSession?.id); - expect(controller.getState().sessions).toHaveLength(2); - expect(controller.getState().activeSessionId).toBe(secondSession?.id); - expect(controller.getState().sessions[0]?.turns).toHaveLength(1); - expect(controller.getState().sessions[0]?.contextualPrompt?.composer.isOpen).toBe( - false, - ); - expect(controller.getState().sessions[1]?.turns).toHaveLength(0); - expect(controller.getState().sessions[1]?.contextualPrompt?.composer.isOpen).toBe( - true, - ); - }); - - it("keeps inline session prompts selection-scoped for follow-up edits", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Add an intro paragraph before this text", - ); - - expect(generation.target).toBe("selection"); - expect(controller.getState().sessions[0]?.turns[0]?.target).toBe("selection"); - expect(editor.documentState.blockOrder).toHaveLength(1); - }); - - it("closes the inline composer when resolving a session", async () => { - const createInlineSessionEditor = () => - createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const acceptEditor = createInlineSessionEditor(); - const acceptBlockId = acceptEditor.firstBlock()!.id; - acceptEditor.apply( - [{ type: "insert-text", blockId: acceptBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - acceptEditor.selectTextRange( - { blockId: acceptBlockId, offset: 6 }, - { blockId: acceptBlockId, offset: 11 }, - ); - const acceptController = getAIController(acceptEditor)!; - const acceptSession = acceptController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await acceptController.runSessionPrompt( - acceptSession.id, - "Rewrite the selection", - ); - - expect(acceptController.resolveSession(acceptSession.id, "accept")).toBe(true); - expect( - acceptController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - const rejectEditor = createInlineSessionEditor(); - const rejectBlockId = rejectEditor.firstBlock()!.id; - rejectEditor.apply( - [{ type: "insert-text", blockId: rejectBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - rejectEditor.selectTextRange( - { blockId: rejectBlockId, offset: 6 }, - { blockId: rejectBlockId, offset: 11 }, - ); - const rejectController = getAIController(rejectEditor)!; - const rejectSession = rejectController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await rejectController.runSessionPrompt( - rejectSession.id, - "Rewrite the selection", - ); - - expect(rejectController.resolveSession(rejectSession.id, "reject")).toBe(true); - expect( - rejectController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - }); - - it("closes the inline composer when resolving a session turn", async () => { - const createInlineSessionEditor = () => - createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const acceptEditor = createInlineSessionEditor(); - const acceptBlockId = acceptEditor.firstBlock()!.id; - acceptEditor.apply( - [{ type: "insert-text", blockId: acceptBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - acceptEditor.selectTextRange( - { blockId: acceptBlockId, offset: 6 }, - { blockId: acceptBlockId, offset: 11 }, - ); - const acceptController = getAIController(acceptEditor)!; - const acceptSession = acceptController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await acceptController.runSessionPrompt( - acceptSession.id, - "Rewrite the selection", - ); - const acceptedTurnId = acceptController.getActiveSession()?.turns[0]?.id; - - expect( - acceptController.resolveSessionTurn( - acceptSession.id, - acceptedTurnId!, - "accept", - ), - ).toBe(true); - expect( - acceptController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - - const rejectEditor = createInlineSessionEditor(); - const rejectBlockId = rejectEditor.firstBlock()!.id; - rejectEditor.apply( - [{ type: "insert-text", blockId: rejectBlockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - rejectEditor.selectTextRange( - { blockId: rejectBlockId, offset: 6 }, - { blockId: rejectBlockId, offset: 11 }, - ); - const rejectController = getAIController(rejectEditor)!; - const rejectSession = rejectController.startSession({ - surface: "inline-edit", - target: "selection", - }); - await rejectController.runSessionPrompt( - rejectSession.id, - "Rewrite the selection", - ); - const rejectedTurnId = rejectController.getActiveSession()?.turns[0]?.id; - - expect( - rejectController.resolveSessionTurn( - rejectSession.id, - rejectedTurnId!, - "reject", - ), - ).toBe(true); - expect( - rejectController.getActiveSession()?.contextualPrompt?.composer.isOpen, - ).toBe(false); - }); - - it("uses the captured inline session selection even if the editor selection changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "planet" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - editor.selectTextRange( - { blockId, offset: 0 }, - { blockId, offset: 5 }, - ); - - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the selection", - ); - - expect(generation.status).toBe("complete"); - expect(editor.getBlock(blockId)!.textContent({ resolved: true })).toBe( - "Hello planet", - ); - }); - - it("routes inline session continue prompts to block streaming suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " More detail" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Continue this paragraph", - ); - - expect(generation.target).toBe("selection"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(editor.getBlock(blockId)!.textContent()).toContain("Hello world"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("routes inline local-edit prompts to block streaming suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " Better version" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Make it better", - ); - - expect(generation.target).toBe("selection"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("uses the live collapsed caret offset for block generations", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " AI" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 5 }, - { blockId, offset: 5 }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Continue this paragraph", { - target: "block", - blockId, - }); - - expect(generation.target).toBe("block"); - const suggestions = controller.getSuggestions(); - expect(suggestions.length).toBeGreaterThan(0); - expect(suggestions[0]).toMatchObject({ - blockId, - offset: 5, - }); - }); - - it("uses the selection end as the insertion offset for inline block turns", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " Better" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Make it better", - ); - - expect(generation.target).toBe("selection"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - const suggestions = controller.getSuggestions(); - expect(suggestions.length).toBeGreaterThan(0); - expect(suggestions[0]?.blockId).toBe(blockId); - }); - - it("creates reviewable cross-block inline edit suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "X" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply([ - { - type: "insert-block", - blockId: "b2", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: "b3", - blockType: "paragraph", - props: {}, - position: "last", - }, - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }, - { type: "insert-text", blockId: "b2", offset: 0, text: "World" }, - { type: "insert-text", blockId: "b3", offset: 0, text: "Again" }, - ]); - editor.selectTextRange( - { blockId: firstBlockId, offset: 2 }, - { blockId: "b3", offset: 2 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "inline-edit", - target: "selection", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the selection", - ); - const nextSession = controller.getActiveSession(); - const turn = nextSession?.turns[0]; - - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - expect(turn?.selection?.isMultiBlock).toBe(true); - expect(turn?.status).toBe("review"); - expect(controller.acceptSessionTurn(session.id, turn!.id)).toBe(true); - expect(editor.getBlock(firstBlockId)?.textContent({ resolved: true })).toBe("HeXain"); - expect(editor.getBlock("b2")).toBeNull(); - expect(editor.getBlock("b3")).toBeNull(); - }); - - it("records progressive tool stream events for the active generation", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - pass += 1; - if (pass === 1) { - yield { - type: "tool-call" as const, - toolCallId: "tool-call-1", - toolName: "test_search", - input: { query: "plan" }, - }; - } - yield { type: "done" as const }; - }, - }, - }), - testStreamingToolExtension(), - ], - }); - const controller = getAIController(editor)!; - const blockId = editor.firstBlock()!.id; - - const generation = await controller.runPrompt("search the document", { blockId }); - const streamEvents = controller.getStreamEvents(); - const streamEventTypes = streamEvents.map((event) => event.type); - const toolOutputEvents = streamEvents.filter( - (event) => event.type === "tool-output", - ); - const toolResultEvent = streamEvents.find( - (event) => event.type === "tool-result", - ); - - expect(generation.status).toBe("complete"); - expect(streamEventTypes).toEqual([ - "generation-start", - "status", - "tool-call", - "status", - "tool-output", - "tool-output", - "tool-result", - "status", - "generation-finish", - ]); - expect(toolOutputEvents).toHaveLength(2); - expect(toolOutputEvents[0]).toMatchObject({ - toolCallId: "tool-call-1", - toolName: "test_search", - part: "searching:plan", - output: "searching:plan", - }); - expect(toolOutputEvents[1]).toMatchObject({ - toolCallId: "tool-call-1", - toolName: "test_search", - part: { matches: 2, query: "plan" }, - output: ["searching:plan", { matches: 2, query: "plan" }], - }); - expect(toolResultEvent).toMatchObject({ - type: "tool-result", - toolCallId: "tool-call-1", - toolName: "test_search", - output: ["searching:plan", { matches: 2, query: "plan" }], - state: "complete", - }); - }); - - it("streams block structured previews before a block plan finishes", async () => { - const releaseSecondDelta = createDeferred(); - let streamedBlockId = ""; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: - `{"kind":"block_convert","blockId":"${streamedBlockId}","newType":"heading"`, - }; - await releaseSecondDelta.promise; - yield { - type: "text-delta" as const, - delta: ',"props":{"level":2}}', - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - streamedBlockId = blockId; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generationPromise = controller.runPrompt("Convert block to heading", { - blockId, - }); - await waitForPreview( - () => controller.getState().activeGeneration?.structuredPreview, - ); - - const activeGeneration = controller.getState().activeGeneration; - const previewEventsBeforeCompletion = controller.getStreamEvents().filter( - (event) => event.type === "structured-preview", - ); - expect(activeGeneration?.structuredPreview).toMatchObject({ - planState: "drafted", - plan: { - kind: "block_convert", - blockId, - newType: "heading", - }, - }); - expect(activeGeneration?.structuredPreview?.reviewItems).toEqual([ - expect.objectContaining({ - label: "Convert block", - section: "block", - changeKind: "updated", - }), - ]); - expect(controller.getStreamEvents().some((event) => ( - event.type === "structured-preview" && - event.preview.plan.kind === "block_convert" - ))).toBe(true); - expect(previewEventsBeforeCompletion).toHaveLength(1); - expect(previewEventsBeforeCompletion[0]).toMatchObject({ - patches: [ - { op: "add", path: "/planState", value: "drafted" }, - { op: "add", path: "/plan", value: expect.any(Object) }, - { op: "add", path: "/reviewItems", value: expect.any(Array) }, - { op: "add", path: "/targets", value: [] }, - ], - }); - - releaseSecondDelta.resolve(); - const generation = await generationPromise; - const previewEventsAfterCompletion = controller.getStreamEvents().filter( - (event) => event.type === "structured-preview", - ); - const finalPreviewEvent = - previewEventsAfterCompletion[previewEventsAfterCompletion.length - 1]; - expect(generation.structuredPreview).toMatchObject({ - planState: "validated", - plan: { - kind: "block_convert", - blockId, - newType: "heading", - props: { level: 2 }, - }, - }); - expect(finalPreviewEvent).toMatchObject({ - patches: [ - { op: "replace", path: "/planState", value: "validated" }, - { op: "add", path: "/plan/props", value: {} }, - { op: "add", path: "/plan/props/level", value: 2 }, - ], - }); - expect( - finalPreviewEvent?.patches.some((patch) => patch.path === "/plan"), - ).toBe(false); - }); - - it("keeps selection rewrites text-only when markdown block generation is enabled", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { blockGeneration: "markdown" }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "# Planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Rewrite the selection"); - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("text"); - expect(editor.getBlock(blockId)!.textContent()).toBe("Hello world# Planet"); - expect(editor.documentState.blockOrder).toHaveLength(1); - }); - - it("routes context-first block edits into persistent suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "text-delta" as const, delta: " Updated" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Improve this paragraph", { blockId }); - const block = editor.getBlock(blockId)!; - - expect(generation.route).toBe("context-first"); - expect(generation.mutationMode).toBe("persistent-suggestions"); - expect(block.textContent()).toBe("Hello Updated"); - expect(controller.getSuggestions().length).toBeGreaterThan(0); - }); - - it("uses markdown block generation for bottom-chat document writing", async () => { - const releaseFinalDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - selectionRewrite: "text", - }, - model: { - async *stream() { - yield { type: "text-delta" as const, delta: "Once upon " }; - await releaseFinalDelta.promise; - yield { type: "text-delta" as const, delta: "a time" }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generationPromise = controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - - await waitForPreview(() => { - const activeGeneration = controller.getState().activeGeneration; - const streamedVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - return ( - activeGeneration?.surface === "bottom-chat" && - activeGeneration.contentFormat === "markdown" && - streamedVisibleBlockTexts.includes("Once upon") - ); - }); - - const streamedVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); - expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); - expect(controller.getState().activeGeneration?.mutationMode).toBe( - "streaming-suggestions", - ); - expect(streamedVisibleBlockTexts).toEqual(["Hello", "Once upon"]); - expect(session.surface).toBe("bottom-chat"); - - releaseFinalDelta.resolve(); - const generation = await generationPromise; - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(generation.status).toBe("complete"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(generation.contentFormat).toBe("markdown"); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.blockClass).toBe("flow"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - expect(visibleBlockTexts).toEqual(["Hello", "Once upon a time"]); - }); - - it("streams bottom-chat markdown as block suggestions before completion", async () => { - const releaseFinalDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - selectionRewrite: "text", - }, - model: { - async *stream(options) { - yield { - type: "replace-preview" as const, - operation: options.operation!, - text: "\n\nOnce upon ", - }; - await releaseFinalDelta.promise; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "\n\nOnce upon a time", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generationPromise = controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - - await new Promise((resolve) => setTimeout(resolve, 80)); - - expect(controller.getState().activeGeneration?.surface).toBe("bottom-chat"); - expect(controller.getState().activeGeneration?.contentFormat).toBe("markdown"); - const visibleStreamingTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect( - (editor.getBlock(blockId)?.textContent({ resolved: true }) ?? "").replace( - /^\u200b/, - "", - ), - ).toBe(""); - expect(visibleStreamingTexts).toEqual(["Once upon"]); - - releaseFinalDelta.resolve(); - const generation = await generationPromise; - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("markdown"); - expect(generation.text).toBe("\n\nOnce upon a time"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - const visibleFinalTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleFinalTexts).toEqual(["Once upon a time"]); - const turnId = controller - .getState() - .sessions.find((item) => item.id === session.id) - ?.turns[0]?.id; - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - const keptTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(keptTexts).toEqual(["Once upon a time"]); - }); - - it("allows inline selection edits after keeping bottom-chat changes", async () => { - let pass = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - selectionRewrite: "text", - }, - model: { - async *stream(options) { - pass += 1; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: pass === 1 ? "Hello world" : "planet", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const bottomChatSession = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - await controller.runSessionPrompt( - bottomChatSession.id, - "Write something in the document", - { target: "document" }, - ); - - const keptTurnId = controller - .getSessions() - .find((session) => session.id === bottomChatSession.id) - ?.turns[0]?.id; - expect(keptTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(bottomChatSession.id, keptTurnId!)).toBe(true); - - const blockId = editor.firstBlock()!.id; - editor.selectTextRange( - { blockId, offset: 6 }, - { blockId, offset: 11 }, - ); - - const inlineSession = controller.openContextualPrompt({ - surface: "inline-edit", - target: "selection", - }); - expect(inlineSession).not.toBeNull(); - - const generation = await controller.runSessionPrompt( - inlineSession!.id, - "Rewrite the selection", - { target: "selection" }, - ); - - expect(generation.target).toBe("selection"); - expect( - controller - .getSessions() - .find((session) => session.id === inlineSession!.id) - ?.turns, - ).toHaveLength(1); - }); - - it("treats whole-document rewrite prompts as explicit multi-block replace plans", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Founder's Last Email\n\nA startup story set in Amsterdam.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "The Lighthouse Keeper's Last Letter" }, - { - type: "insert-block", - blockId: "paragraph-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "paragraph-2", - offset: 0, - text: "The storm had been building for three days.", - }, - ], - { origin: "system" }, - ); - const originalBlockIds = [...editor.documentState.blockOrder]; - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the whole story. Make it about a startup from Amsterdam.", - { target: "document" }, - ); - - const activeSession = controller.getSessions().find((item) => item.id === session.id); - expect(activeSession?.operation?.kind).toBe("rewrite-selection"); - expect(activeSession?.operation?.target.kind).toBe("scoped-range"); - const documentTarget = - activeSession?.operation?.target.kind === "scoped-range" - ? activeSession.operation.target - : null; - expect(documentTarget?.blockIds).toEqual(originalBlockIds); - expect(documentTarget?.contentFormat).toBe("markdown"); - expect(documentTarget?.scope).toBe("document"); - expect(generation.status).toBe("complete"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = activeSession?.turns[0]?.id; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - - const finalVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(finalVisibleBlockTexts).toEqual([ - "The Founder's Last Email", - "A startup story set in Amsterdam.", - ]); - }); - - it("treats rewrite-the-story prompts as explicit multi-block replace plans", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "# The Pharaoh's Last Scroll\n\nA cat story set in Egypt.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { - type: "insert-text", - blockId: firstBlockId, - offset: 0, - text: "The Founder's Last Email", - }, - { - type: "insert-block", - blockId: "paragraph-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "paragraph-2", - offset: 0, - text: "The Slack notification had been pinging for three days.", - }, - ], - { origin: "system" }, - ); - const originalBlockIds = [...editor.documentState.blockOrder]; - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Rewrite the story. Make it about a cat from Egypt.", - { target: "document" }, - ); - - const activeSession = controller.getSessions().find((item) => item.id === session.id); - expect(activeSession?.operation?.kind).toBe("rewrite-selection"); - expect(activeSession?.operation?.target.kind).toBe("scoped-range"); - const documentTarget = - activeSession?.operation?.target.kind === "scoped-range" - ? activeSession.operation.target - : null; - expect(documentTarget?.blockIds).toEqual(originalBlockIds); - expect(documentTarget?.contentFormat).toBe("markdown"); - expect(documentTarget?.scope).toBe("document"); - expect(generation.status).toBe("complete"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const turnId = activeSession?.turns[0]?.id; - expect(turnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, turnId!)).toBe(true); - - const finalVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(finalVisibleBlockTexts).toEqual([ - "The Pharaoh's Last Scroll", - "A cat story set in Egypt.", - ]); - }); - - it("carries bottom-chat history into follow-up title edits and replaces prior generated blocks", async () => { - const capturedPrompts: string[] = []; - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - streamCount += 1; - capturedPrompts.push( - options.messages - .map((message) => - typeof message.content === "string" - ? message.content - : JSON.stringify(message.content), - ) - .join("\n\n"), - ); - yield { - type: "replace-final" as const, - operation: options.operation!, - text: - streamCount === 1 - ? "# Salt and Shadow\n\nA lighthouse story." - : "# Amsterdam Sprint\n\nA startup story with a new title.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - await controller.runSessionPrompt(session.id, "Write a story", { - target: "document", - }); - - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt( - session.id, - "Also change the title.", - { target: "document" }, - ); - - expect(capturedPrompts[1]).toContain( - "Earlier user requests in this same session:", - ); - expect(capturedPrompts[1]).toContain("1. Write a story"); - expect(capturedPrompts[1]).toContain( - "Latest request:\nAlso change the title.", - ); - const activeSession = controller.getSessions().find((item) => item.id === session.id); - expect(activeSession?.operation?.kind).toBe("rewrite-selection"); - expect(activeSession?.operation?.target.kind).toBe("scoped-range"); - const documentTarget = - activeSession?.operation?.target.kind === "scoped-range" - ? activeSession.operation.target - : null; - expect(documentTarget?.scope).toBe("heading"); - expect(documentTarget?.contentFormat).toBe("markdown"); - expect(documentTarget?.blockIds).toHaveLength(1); - }); - - it("replaces the previous story after accepting a follow-up make-it-about rewrite", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - streamCount += 1; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: - streamCount === 1 - ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." - : "# The Cat Keeper's Last Purr\n\nA cat story.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - await controller.runSessionPrompt(session.id, "Write a story", { - target: "document", - }); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt(session.id, "Actually make it about cats", { - target: "document", - }); - const secondTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.id ?? null; - expect(secondTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); - - const finalVisibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(finalVisibleBlockTexts).toEqual([ - "The Cat Keeper's Last Purr", - "A cat story.", - ]); - }); - - it("restores the previous accepted story when undoing a kept follow-up rewrite", async () => { - let streamCount = 0; - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - streamCount += 1; - yield { - type: "replace-final" as const, - operation: options.operation!, - text: - streamCount === 1 - ? "# The Lighthouse Keeper's Last Signal\n\nA lighthouse story." - : "# The Cat Keeper's Last Purr\n\nA cat story.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - await controller.runSessionPrompt(session.id, "Write a story", { - target: "document", - }); - const firstTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[0]?.id ?? null; - expect(firstTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, firstTurnId!)).toBe(true); - - await controller.runSessionPrompt(session.id, "Actually make it about cats", { - target: "document", - }); - const secondTurnId = - controller - .getSessions() - .find((item) => item.id === session.id) - ?.turns[1]?.id ?? null; - expect(secondTurnId).toBeTruthy(); - expect(controller.acceptSessionTurn(session.id, secondTurnId!)).toBe(true); - - expect(editor.undoManager.undo()).toBe(true); - - const visibleBlockTextsAfterUndo = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - expect(visibleBlockTextsAfterUndo).toEqual([ - "The Lighthouse Keeper's Last Signal", - "A lighthouse story.", - ]); - }); - - it("trims leading blank lines when bottom-chat writes into an empty block", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "\n\nOnce upon a time", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(visibleBlockTexts).toEqual(["Once upon a time"]); - }); - - it("materializes bottom-chat paragraphs as separate blocks for empty targets", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream(options) { - yield { - type: "replace-final" as const, - operation: options.operation!, - text: "First paragraph.\n\nSecond paragraph.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const blockId = editor.firstBlock()!.id; - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write two paragraphs", - { target: "document" }, - ); - - const visibleBlockTexts = editor.documentState.blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(visibleBlockTexts).toEqual([ - "First paragraph.", - "Second paragraph.", - ]); - }); - - it("reuses a leading empty placeholder for document-target bottom-chat writes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "Story opener.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const placeholderBlockId = editor.firstBlock()!.id; - const trailingBlockId = "trailing-block"; - editor.apply( - [ - { - type: "insert-block", - blockId: trailingBlockId, - blockType: "paragraph", - props: {}, - position: { after: placeholderBlockId }, - }, - { - type: "insert-text", - blockId: trailingBlockId, - offset: 0, - text: "Existing content", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write a short story", - { target: "document" }, - ); - const blockOrder = editor.documentState.blockOrder; - const visibleBlockTexts = blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(blockOrder).toHaveLength(3); - expect(visibleBlockTexts).toEqual(["Story opener.", "Existing content"]); - expect(readBlockSuggestionMeta(editor.getBlock(placeholderBlockId))?.action).toBe( - "delete-block", - ); - }); - - it("prefers the caret block over unrelated empty placeholders for document-target writes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "Follow the caret.", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const placeholderBlockId = editor.firstBlock()!.id; - const caretBlockId = "caret-block"; - editor.apply( - [ - { - type: "insert-block", - blockId: caretBlockId, - blockType: "paragraph", - props: {}, - position: { after: placeholderBlockId }, - }, - { - type: "insert-text", - blockId: caretBlockId, - offset: 0, - text: "Existing content", - }, - ], - { origin: "system" }, - ); - editor.selectTextRange( - { blockId: caretBlockId, offset: 8 }, - { blockId: caretBlockId, offset: 8 }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - - const generation = await controller.runSessionPrompt( - session.id, - "Write more here", - { target: "document" }, - ); - const blockOrder = editor.documentState.blockOrder; - const visibleBlockTexts = blockOrder - .map((id) => editor.getBlock(id)?.textContent({ resolved: true }) ?? "") - .filter((text) => text.trim().length > 0); - - expect(generation.status).toBe("complete"); - expect(blockOrder).toHaveLength(3); - expect(visibleBlockTexts).toEqual(["Existing content", "Follow the caret."]); - }); - - it("creates tables through markdown for bottom-chat document prompts", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: "| Tier | Price |\n| --- | --- |\n| Pro | $20 |", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const introBlockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Create a pricing table", - { target: "document" }, - ); - - expect(generation.status).toBe("complete"); - expect(generation.contentFormat).toBe("markdown"); - expect(generation.planState).toBe("none"); - expect(generation.reviewItems).toEqual([]); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.blockClass).toBe("flow"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationMode).toBe("streaming-suggestions"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableCell(0, 0)?.textContent()).toBe("Tier"); - expect(tables[0]?.tableCell(0, 1)?.textContent()).toBe("Price"); - expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Pro"); - expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("$20"); - expect(controller.acceptActiveGeneration()).toBe(true); - }); - - it("streams markdown table suggestions before completion for bottom-chat document prompts", async () => { - const releaseFinalDelta = createDeferred(); - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: - "| First Name | Last Name |\n| --- | --- |\n| Alice | Johnson |", - }; - await releaseFinalDelta.promise; - yield { - type: "text-delta" as const, - delta: "\n| Bob | Smith |", - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const introBlockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId: introBlockId, offset: 0, text: "Intro" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generationPromise = controller.runSessionPrompt( - session.id, - "Create a table with names in it", - { target: "document" }, - ); - - await waitForPreview(() => { - const tables = Array.from(editor.blocks("table")); - return tables[0]?.tableCell(1, 0)?.textContent() === "Alice"; - }); - - expect(controller.getState().activeGeneration?.adapterId).toBe("flow-markdown"); - expect(controller.getState().activeGeneration?.blockClass).toBe("flow"); - expect(controller.getState().activeGeneration?.transportKind).toBe("flow-text"); - expect(controller.getState().activeGeneration?.mutationMode).toBe( - "streaming-suggestions", - ); - const previewTables = Array.from(editor.blocks("table")); - expect(previewTables).toHaveLength(1); - expect(previewTables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); - expect(previewTables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); - - releaseFinalDelta.resolve(); - const generation = await generationPromise; - - expect(generation.planState).toBe("none"); - expect(generation.reviewItems).toEqual([]); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.blockClass).toBe("flow"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); - expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Johnson"); - expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); - expect(tables[0]?.tableCell(2, 1)?.textContent()).toBe("Smith"); - }); - - it("builds rich preview details for newly inserted databases during direct bottom-chat apply", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - contentFormat: { - blockGeneration: "markdown", - }, - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "review_bundle", - label: "Create task database", - reason: "Insert and seed a task database.", - plans: [ - { - kind: "block_insert", - blockId: "task-db", - blockType: "database", - position: "last", - }, - { - kind: "database_edit", - blockId: "task-db", - steps: [ - { - op: "insert_row", - rowId: "row-1", - values: { - name: "Ship docs", - tags: "[\"docs\"]", - done: "false", - }, - }, - { - op: "add_view", - view: { - id: "view-list", - title: "List view", - type: "list", - visibleColumnIds: ["name", "tags"], - columnOrder: ["name", "tags", "done"], - }, - }, - ], - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - - const controller = getAIController(editor)!; - const session = controller.startSession({ - surface: "bottom-chat", - target: "document", - }); - const generation = await controller.runSessionPrompt( - session.id, - "Create a task database table with views", - { target: "document" }, - ); - - expect(generation.planState).toBe("validated"); - expect(generation.structuredPreview?.targets).toEqual([ - expect.objectContaining({ - blockId: "task-db", - targetKind: "database", - database: expect.objectContaining({ - columns: expect.arrayContaining([ - expect.objectContaining({ id: "name" }), - expect.objectContaining({ id: "tags" }), - expect.objectContaining({ id: "done" }), - ]), - rows: [ - expect.objectContaining({ - id: "row-1", - values: expect.objectContaining({ - name: "Ship docs", - }), - }), - ], - views: expect.arrayContaining([ - expect.objectContaining({ id: "view-table" }), - expect.objectContaining({ id: "view-list" }), - ]), - }), - }), - ]); - expect(generation.reviewItems).toEqual([]); - expect(editor.getBlock("task-db")?.type).toBe("database"); - }); - - it("replaces existing tables through markdown suggestions", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "| Name |", - "| --- |", - "| Alice |", - "| Bob |", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Add a row to this table", { - blockId: "table-1", - }); - - expect(generation.status).toBe("complete"); - expect(generation.targetKind).toBe("table"); - expect(generation.planState).toBe("none"); - expect(generation.plan).toBeNull(); - expect(generation.adapterId).toBe("flow-markdown"); - expect(generation.transportKind).toBe("flow-text"); - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - expect(generation.reviewItems).toEqual([]); - expect(generation.debug?.structured).toMatchObject({ - plannerMode: "text", - targetKind: "table", - validationIssueCount: 0, - }); - expect(generation.suggestionIds?.length ?? 0).toBeGreaterThan(0); - expect(editor.getBlock("table-1")?.tableRowCount()).toBe(initialRowCount); - }); - - it("accepts markdown table suggestions through the controller", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "| Name |", - "| --- |", - "| Alice |", - "| Bob |", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); - - const controller = getAIController(editor)!; - await controller.runPrompt("Add a row to this table", { - blockId: "table-1", - }); - - expect(controller.acceptActiveGeneration()).toBe(true); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableRowCount()).toBe(initialRowCount + 1); - expect(tables[0]?.tableCell(1, 0)?.textContent()).toBe("Alice"); - expect(tables[0]?.tableCell(2, 0)?.textContent()).toBe("Bob"); - expect(controller.getState().activeGeneration?.plan).toBeNull(); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - }); - - it("rejects markdown table suggestions without mutating the table", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "| Name |", - "| --- |", - "| Alice |", - "| Bob |", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - const initialRowCount = editor.getBlock("table-1")!.tableRowCount(); - - const controller = getAIController(editor)!; - await controller.runPrompt("Add a row to this table", { - blockId: "table-1", - }); - - expect(controller.rejectActiveGeneration()).toBe(true); - expect(editor.getBlock("table-1")!.tableRowCount()).toBe(initialRowCount); - expect(Array.from(editor.blocks("table"))).toHaveLength(1); - expect(controller.getState().activeGeneration?.plan).toBeNull(); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - expect(controller.getState().activeGeneration?.planState).toBe("rejected"); - }); - - it("applies XML flow patch plans through the markdown fast-apply path", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: [ - "", - "I am replacing the current table with an updated version.", - "adjacent-blocks", - "span:table-1", - "", - "replace_blocks", - "table-1", - "table", - "", - "", - "", - ].join("\n"), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "table-1", - blockType: "table", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Add a role column to this table", { - blockId: "table-1", - }); - - expect(generation.mutationReceipt?.status).toBe("staged_suggestions"); - - expect(controller.acceptActiveGeneration()).toBe(true); - const tables = Array.from(editor.blocks("table")); - expect(tables).toHaveLength(1); - expect(tables[0]?.tableColumnCount()).toBe(2); - expect(tables[0]?.tableRowCount()).toBe(3); - expect(tables[0]?.tableCell(1, 1)?.textContent()).toBe("Design"); - }); - - it("records flow patch alignment metrics in fast-apply debug state", () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Bravo", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const controllerAny = controller as any; - controllerAny._state.activeGeneration = { - id: "test-generation", - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - - const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( - firstBlockId, - [ - "", - "I am inserting a new paragraph between Bravo and Charlie.", - "adjacent-blocks", - `span:${firstBlockId}`, - "", - "replace_blocks", - `${firstBlockId}`, - "block-2", - "block-3", - "", - "", - "", - ].join("\n"), - "persistent-suggestions", - undefined, - { - context: { - markdown: ["Alpha", "", "Bravo", "", "Charlie"].join("\n"), - markdownWindow: { - blockIds: [firstBlockId, "block-2", "block-3"], - }, - }, - }, - ); - - expect(mutationReceipt?.status).toBe("staged_suggestions"); - expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - alignment: { - preservedBlockCount: 3, - rewrittenBlockCount: 0, - unchangedBlockCount: 3, - insertedBlockCount: 1, - deletedBlockCount: 0, - estimatedOperationCost: 2, - }, - }); - }); - - it("records scoped replacement fallback metrics in fast-apply debug state", () => { - const editor = createEditor({ - extensions: [aiExtension({})], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const controllerAny = controller as any; - controllerAny._state.activeGeneration = { - id: "test-generation", - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - - const mutationReceipt = controllerAny._commitBufferedMarkdownFastApply( - firstBlockId, - [ - "", - "I am inserting a middle paragraph.", - "", - "", - "", - "", - "Bravo", - "", - "]]>", - "", - ].join("\n"), - "persistent-suggestions", - undefined, - { - context: { - markdown: ["Alpha", "", "Charlie"].join("\n"), - markdownWindow: { - blockIds: [firstBlockId, "block-2"], - }, - }, - }, - ); - - expect(mutationReceipt?.status).toBe("staged_suggestions"); - expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - fallback: { - kind: "scoped-replacement", - opsCount: 8, - insertedBlockCount: 3, - deletedBlockCount: 2, - targetBlockCount: 2, - }, - }); - }); - - it("records plain markdown fallback metrics when fast-apply falls back to block generation", () => { - const editor = createEditor({ - extensions: [aiExtension({ contentFormat: { blockGeneration: "markdown" } })], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId: firstBlockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const controllerAny = controller as any; - controllerAny._state.activeGeneration = { - id: "test-generation", - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - - const mutationReceipt = controllerAny._commitBufferedBlockGeneration( - firstBlockId, - "## Replacement title", - "persistent-suggestions", - "markdown", - undefined, - { - applyStrategy: "markdown-fast-apply", - workingSet: { - context: { - markdown: "Hello", - markdownWindow: { - blockIds: [firstBlockId], - }, - }, - }, - }, - ); - - expect(mutationReceipt?.status).toBe("staged_suggestions"); - expect(controller.getState().activeGeneration?.debug?.fastApply).toMatchObject({ - attempted: true, - succeeded: false, - fallbackReason: "unparseable-contract", - executionPath: "plain-markdown", - fallback: { - kind: "plain-markdown", - opsCount: 2, - insertedBlockCount: 1, - deletedBlockCount: 0, - }, - }); - }); - - it("executes review-safe block convert plans through the existing suggestion path", async () => { - let blockId = ""; - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "block_convert", - blockId, - newType: "heading", - props: { level: 2 }, - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello" }], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Convert block to heading", { - blockId, - }); - const block = editor.getBlock(blockId)!; - - expect(generation.planState).toBe("validated"); - expect(generation.plan).toMatchObject({ - kind: "block_convert", - blockId, - newType: "heading", - }); - expect(block.type).toBe("heading"); - expect(block.meta("suggestion")).toMatchObject({ - action: "convert-block", - authorType: "ai", - }); - }); - - it("keeps the controller state snapshot stable for no-op updates", () => { - const editor = createEditor({ - extensions: [aiExtension()], - }); - - const controller = getAIController(editor)!; - const initialState = controller.getState(); - - controller.setSuggestMode(false); - expect(controller.getState()).toBe(initialState); - - controller.closeCommandMenu(); - expect(controller.getState()).toBe(initialState); - - controller.dismissEphemeralSuggestion(); - expect(controller.getState()).toBe(initialState); - }); - - it("builds database review items with before and after cell previews", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - - expect(generation.reviewItems).toEqual([ - expect.objectContaining({ - label: "Update cell", - changeKind: "updated", - section: "cell", - detail: "Alpha · Name", - before: "Alpha", - after: "Beta", - }), - ]); - }); - - it("keeps accepted structured review items in document undo history", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - const reviewItems = generation.reviewItems ?? []; - const reviewItemIds = reviewItems.map((item) => item.id); - - expect(generation.planState).toBe("validated"); - expect(reviewItems).toHaveLength(1); - expect(reviewItemIds).toHaveLength(1); - - expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Beta", - ); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Alpha", - ); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Beta", - ); - }); - - it("treats structured review rejection as non-mutating UI state", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - const reviewItems = generation.reviewItems ?? []; - const reviewItemIds = reviewItems.map((item) => item.id); - - expect(reviewItemIds).toHaveLength(1); - expect(controller.rejectReviewItems(reviewItemIds)).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Alpha", - ); - expect(editor.undoManager.canUndo()).toBe(false); - expect(editor.undoManager.undo()).toBe(false); - expect(controller.getState().activeGeneration?.planState).toBe("rejected"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - }); - - it("keeps accepted structured review artifacts transient across history replay", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: "Beta", - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { - name: "Alpha", - tags: "[]", - done: "false", - }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Update this database cell", { - blockId: "database-1", - }); - const reviewItems = generation.reviewItems ?? []; - const reviewItemIds = reviewItems.map((item) => item.id); - - expect(reviewItemIds).toHaveLength(1); - expect(controller.acceptReviewItems(reviewItemIds)).toBe(true); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - - expect(editor.undoManager.undo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Alpha", - ); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - - expect(editor.undoManager.redo()).toBe(true); - expect(editor.getBlock("database-1")!.tableCell(0, 0)?.textContent()).toBe( - "Beta", - ); - expect(controller.getState().activeGeneration?.planState).toBe("none"); - expect(controller.getState().activeGeneration?.reviewItems).toEqual([]); - }); - - it("builds comparison rows for database view changes", async () => { - const editor = createEditor({ - extensions: [ - aiExtension({ - model: { - async *stream() { - yield { - type: "text-delta" as const, - delta: JSON.stringify({ - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "add_view", - view: { - id: "view-list", - title: "List view", - type: "list", - visibleColumnIds: ["name", "tags"], - columnOrder: ["name", "tags", "done"], - sort: [{ columnId: "name", direction: "asc" }], - filter: null, - groupBy: "tags", - pageIndex: 0, - pageSize: 50, - }, - }, - ], - }), - }; - yield { type: "done" as const }; - }, - }, - }), - ], - }); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstBlockId, offset: 0, text: "Intro" }, - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: { after: firstBlockId }, - }, - ], - { origin: "system" }, - ); - - const controller = getAIController(editor)!; - const generation = await controller.runPrompt("Add a grouped list view", { - blockId: "database-1", - }); - - expect(generation.reviewItems).toEqual([ - expect.objectContaining({ - label: "Add view", - comparisonRows: expect.arrayContaining([ - expect.objectContaining({ - label: "View", - after: "List view", - changeKind: "added", - section: "view", - }), - expect.objectContaining({ - label: "Group by", - after: "Tags", - changeKind: "updated", - section: "view", - }), - expect.objectContaining({ - label: "Visible columns", - after: "Name, Tags", - changeKind: "updated", - section: "view", - }), - ]), - }), - ]); - }); -}); +describe.skip("aiExtension split entrypoint", () => {}); diff --git a/packages/extensions/ai/src/__tests__/extension.testUtils.ts b/packages/extensions/ai/src/__tests__/extension.testUtils.ts new file mode 100644 index 0000000..ffb9588 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/extension.testUtils.ts @@ -0,0 +1,53 @@ +import { defineExtension, type ToolRuntime } from "@pen/types"; + +export function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +export function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +export async function waitForPreview( + readPreview: () => unknown, + maxTicks = 10, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readPreview()) { + return; + } + await Promise.resolve(); + } +} diff --git a/packages/extensions/ai/src/__tests__/externalInlineTurnRegistry.test.ts b/packages/extensions/ai/src/__tests__/externalInlineTurnRegistry.test.ts new file mode 100644 index 0000000..326c8f4 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/externalInlineTurnRegistry.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + ExternalInlineTurnRegistry, + canRegisterExternalInlineTurn, +} from "../runtime/externalInlineTurnRegistry"; +import type { AIInlineHistorySnapshot } from "../types"; + +describe("ExternalInlineTurnRegistry", () => { + it("resolves undo transition by snapshot ids", () => { + const registry = new ExternalInlineTurnRegistry(); + registry.set("history-1", { + sessionId: "session-1", + turnId: "turn-1", + historyId: "history-1", + operations: [], + suggestionIds: ["s-1"], + beforeSnapshotId: "before", + afterSnapshotId: "after", + }); + + const current = snapshotWithId("after"); + const target = snapshotWithId("before"); + const result = registry.resolveTransition( + current, + target, + "undo", + () => false, + ); + + expect(result?.historyId).toBe("history-1"); + }); + + it("blocks duplicate registration", () => { + const registry = new ExternalInlineTurnRegistry(); + registry.set("history-1", { + sessionId: "session-1", + turnId: "turn-1", + historyId: "history-1", + operations: [{ type: "insert-text", blockId: "b", offset: 0, text: "x" }], + suggestionIds: ["s-1"], + }); + + expect( + canRegisterExternalInlineTurn( + { + sessionId: "session-1", + turnId: "turn-1", + historyId: "history-1", + operations: [], + suggestionIds: ["s-2"], + }, + registry, + ), + ).toBe(false); + }); +}); + +function snapshotWithId(id: string): AIInlineHistorySnapshot { + return { + id, + sessionId: null, + documentVersion: 1, + activeSessionId: null, + sessions: [], + kind: "document-coupled", + }; +} diff --git a/packages/extensions/ai/src/__tests__/reviewPresentation.streamingPreview.test.ts b/packages/extensions/ai/src/__tests__/reviewPresentation.streamingPreview.test.ts new file mode 100644 index 0000000..3a3541c --- /dev/null +++ b/packages/extensions/ai/src/__tests__/reviewPresentation.streamingPreview.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, it } from "vitest"; +import { + buildAIReviewPresentationDecorations, + buildStreamingReviewPreviewDecorations, +} from "../review/reviewPresentation"; +import { + buildMacBookProStreamingPreviewText, + createAlphaBetaFriendReviewEditor, + createAlphaBetaGammaReviewEditor, + createHelloExclamationSuggestionEditor, + createInlineSession, + createMacBookThreeBlockReviewEditor, + createReviewEditor, + HI_TEXT_RANGE_STREAMING_PREVIEW, + MACBOOK_REPLACEMENT_ORIGINAL_TEXT, + MACBOOK_REPLACEMENT_PARAGRAPH_TEXT, + MACBOOK_THREE_BLOCK_PARTS, + readDecorationAttributes, + readVirtualText, +} from "./reviewPresentation.testHelpers"; + +describe("AI review presentation streaming preview", () => { + it("builds virtual final-text preview decorations without suggestion ids", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: "Hello", + deltas: [{ insert: "Hello" }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: 5, + }, + text: "Hello world", + previousTextLength: 5, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(decorations).toHaveLength(6); + expect(decorations[0]).toMatchObject({ + blockId: "body-1", + from: 5, + key: "ai-streaming-review-preview:session-1:turn-1:2:new:body-1:5:5", + virtualText: " ", + attributes: { + "data-pen-ai-review-preview-virtual": true, + "data-pen-ai-review-preview-new": true, + "data-pen-ai-preview-revision": 2, + }, + }); + expect( + String(readDecorationAttributes(decorations[1])?.style), + ).toContain("animation-delay: 4ms"); + expect( + readDecorationAttributes(decorations[0])?.["data-suggestion-id"], + ).toBe(undefined); + }); + + it("animates only newly received insertion characters", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: "Hello", + deltas: [{ insert: "Hello" }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "insertion-point", + blockId: "body-1", + offset: 5, + }, + text: " world", + previousTextLength: 3, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + const newDecorations = decorations.filter( + (decoration) => + readDecorationAttributes(decoration)?.[ + "data-pen-ai-review-preview-new" + ] === true, + ); + + expect(readVirtualText(decorations)).toBe(" world"); + expect(newDecorations).toHaveLength(3); + expect(readDecorationAttributes(newDecorations[1])?.style).toContain( + "animation-delay: 4ms", + ); + }); + + it("streams only the changed tail for same-block word replacements", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: "Hello John", + deltas: [{ insert: "Hello John" }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: 10, + }, + text: "Hello Sarah", + previousTextLength: 8, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-1", + from: 6, + to: 10, + attributes: expect.objectContaining({ + "data-pen-ai-review-role": "delete-hidden", + "data-pen-final-text-review-hidden": true, + }), + }), + ); + expect(readVirtualText(decorations)).toBe("Sarah"); + expect(readVirtualText(decorations)).not.toContain("Hello "); + }); + + it("preserves unchanged suffixes for same-block word replacements", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: "Hello John, how are you?", + deltas: [{ insert: "Hello John, how are you?" }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: 24, + }, + text: "Hello Sarah, how are you?", + previousTextLength: 9, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-1", + from: 6, + to: 10, + attributes: expect.objectContaining({ + "data-pen-ai-review-role": "delete-hidden", + "data-pen-final-text-review-hidden": true, + }), + }), + ); + expect(readVirtualText(decorations)).toBe("Sarah"); + expect(readVirtualText(decorations)).not.toContain(", how are you?"); + }); + + it("does not hide the unchanged tail while a full replacement streams", () => { + const originalText = MACBOOK_REPLACEMENT_ORIGINAL_TEXT; + const previewText = buildMacBookProStreamingPreviewText(originalText); + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: originalText, + deltas: [{ insert: originalText }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: originalText.length, + }, + text: previewText, + previousTextLength: previewText.length - "Pro ".length, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(readVirtualText(decorations)).toBe("Pro "); + expect(decorations).not.toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-1", + from: expect.any(Number), + to: originalText.length, + attributes: expect.objectContaining({ + "data-pen-ai-review-role": "delete-hidden", + }), + }), + ); + }); + + it("does not hide unchanged blocks while a full block-range replacement streams", () => { + const { firstBlockText, secondBlockText, thirdBlockText } = + MACBOOK_THREE_BLOCK_PARTS; + const originalText = [firstBlockText, secondBlockText, thirdBlockText].join( + "\n", + ); + const previewText = buildMacBookProStreamingPreviewText(originalText); + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createMacBookThreeBlockReviewEditor(), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "block-range", + start: { blockId: "body-1", offset: 0 }, + end: { blockId: "body-3", offset: thirdBlockText.length }, + blockIds: ["body-1", "body-2", "body-3"], + }, + text: previewText, + previousTextLength: previewText.length - "Pro ".length, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(readVirtualText(decorations)).toBe("Pro "); + expect(decorations).not.toContainEqual( + expect.objectContaining({ + type: "block", + }), + ); + }); + + it("does not add final-text context over the full selection during streaming preview", () => { + const originalText = MACBOOK_REPLACEMENT_PARAGRAPH_TEXT; + const previewText = buildMacBookProStreamingPreviewText(originalText); + const decorations = buildAIReviewPresentationDecorations({ + activeGeneration: { + id: "generation-1", + sessionId: "session-1", + status: "streaming", + } as never, + activeSessionId: "session-1", + editor: createReviewEditor({ + blockId: "body-1", + text: originalText, + deltas: [{ insert: originalText }], + }), + sessions: [ + createInlineSession({ + focusOffset: originalText.length, + pendingSuggestionIds: [], + }), + ], + streamingReviewPreview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: originalText.length, + }, + text: previewText, + previousTextLength: previewText.length - "Pro ".length, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + const contextDecorations = decorations.filter( + (decoration) => + decoration.type === "inline" && + decoration.attributes?.["data-pen-ai-review-role"] === + "context", + ); + + expect(readVirtualText(decorations)).toBe("Pro "); + expect(contextDecorations).toEqual([]); + }); + + it("previews streamed removals before final suggestions are applied", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: "Hello world", + deltas: [{ insert: "Hello world" }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: 11, + }, + text: "Hello", + previousTextLength: 5, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-1", + from: 5, + to: 11, + attributes: expect.objectContaining({ + "data-pen-ai-review-role": "delete-hidden", + "data-pen-final-text-review-hidden": true, + }), + }), + ); + expect(readVirtualText(decorations)).toBe(""); + }); + + it("streams replacement text with newlines as a single linear virtual preview", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createReviewEditor({ + blockId: "body-1", + text: "Hello", + deltas: [{ insert: "Hello" }], + }), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "text-range", + blockId: "body-1", + from: 0, + to: 5, + }, + text: "Hello\n\nWorld", + previousTextLength: 5, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(readVirtualText(decorations)).toBe("\n\nWorld"); + expect(decorations).not.toContainEqual( + expect.objectContaining({ + type: "block", + }), + ); + }); + + it("previews multi-block range removals while replacement text streams", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createAlphaBetaGammaReviewEditor(), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "block-range", + start: { blockId: "body-1", offset: 2 }, + end: { blockId: "body-3", offset: 3 }, + blockIds: ["body-1", "body-2", "body-3"], + }, + text: "Replacement", + previousTextLength: 0, + revision: 1, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-1", + from: 2, + to: 5, + }), + ); + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-3", + from: 0, + to: 3, + }), + ); + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "block", + blockId: "body-2", + attributes: expect.objectContaining({ + "data-pen-ai-review-role": "block-delete", + }), + }), + ); + expect(readVirtualText(decorations)).toContain("Replacement"); + }); + + it("streams same-paragraph multi-block replacements at changed words", () => { + const decorations = buildStreamingReviewPreviewDecorations({ + editor: createAlphaBetaFriendReviewEditor(), + preview: { + sessionId: "session-1", + turnId: "turn-1", + target: { + kind: "block-range", + start: { blockId: "body-1", offset: 0 }, + end: { blockId: "body-2", offset: "Beta friend".length }, + blockIds: ["body-1", "body-2"], + }, + text: "Alpha teammate\nBeta friend", + previousTextLength: "Alpha ".length, + revision: 2, + updatedAt: 123, + }, + suggestionPresentation: "final-text", + }); + + expect(decorations).toContainEqual( + expect.objectContaining({ + type: "inline", + blockId: "body-1", + from: "Alpha ".length, + to: "Alpha friend".length, + }), + ); + expect(decorations).not.toContainEqual( + expect.objectContaining({ + type: "block", + }), + ); + expect(readVirtualText(decorations)).toBe("teammate"); + expect(readVirtualText(decorations)).not.toContain("Alpha "); + expect(readVirtualText(decorations)).not.toContain("Beta friend"); + }); + + it("includes streaming preview decorations alongside unrelated persistent suggestions", () => { + const editor = createReviewEditor({ + blockId: "body-1", + text: "Hello", + deltas: [{ insert: "Hello" }], + }); + + const decorations = buildAIReviewPresentationDecorations({ + activeSessionId: "session-1", + editor, + sessions: [createInlineSession()], + suggestionPresentation: "final-text", + streamingReviewPreview: HI_TEXT_RANGE_STREAMING_PREVIEW, + }); + + expect(readVirtualText(decorations)).toContain("i"); + + const decorationsWithSuggestion = buildAIReviewPresentationDecorations({ + activeSessionId: "session-1", + editor: createHelloExclamationSuggestionEditor(), + sessions: [createInlineSession()], + suggestionPresentation: "final-text", + streamingReviewPreview: HI_TEXT_RANGE_STREAMING_PREVIEW, + }); + + expect(readVirtualText(decorationsWithSuggestion)).toContain("i"); + }); +}); + diff --git a/packages/extensions/ai/src/__tests__/reviewPresentation.test.ts b/packages/extensions/ai/src/__tests__/reviewPresentation.test.ts new file mode 100644 index 0000000..1c41340 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/reviewPresentation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + buildAIReviewPresentationDecorations, + resolveAIReviewPresentationState, +} from "../review/reviewPresentation"; +import { + createInlineSession, + createReviewEditor, +} from "./reviewPresentation.testHelpers"; + +describe("AI review presentation", () => { + it("keeps affected context out of inserted suggestion ranges", () => { + const editor = createReviewEditor({ + blockId: "body-1", + text: "Sounds good eat", + deltas: [ + { insert: "Sounds " }, + { + insert: "good", + attributes: { + suggestion: { + id: "suggestion-insert-1", + action: "insert", + author: "assistant", + authorType: "ai", + }, + }, + }, + { insert: " eat" }, + ], + }); + + const decorations = buildAIReviewPresentationDecorations({ + activeSessionId: "session-1", + editor, + sessions: [createInlineSession()], + suggestionPresentation: "track-changes", + }); + const inlineDecorations = decorations.filter( + (decoration) => decoration.type === "inline", + ); + const insertDecoration = inlineDecorations.find( + (decoration) => + decoration.attributes?.["data-suggestion-id"] === + "suggestion-insert-1", + ); + const contextDecorations = inlineDecorations.filter( + (decoration) => + decoration.attributes?.["data-pen-ai-review-role"] === + "context", + ); + + expect(insertDecoration?.from).toBe(7); + expect(insertDecoration?.to).toBe(11); + expect(insertDecoration?.attributes?.["data-ai-affected-range"]).toBe( + undefined, + ); + expect(insertDecoration?.attributes?.["data-pen-ai-review-role"]).toBe( + "insert", + ); + expect( + contextDecorations.map(({ from, to }) => ({ from, to })), + ).toEqual([ + { from: 0, to: 7 }, + { from: 11, to: 15 }, + ]); + }); + + it("keeps final-text review focused on the diff instead of painting the whole selection", () => { + const editor = createReviewEditor({ + blockId: "body-1", + text: "Sounds good eat", + deltas: [ + { insert: "Sounds " }, + { + insert: "good", + attributes: { + suggestion: { + id: "suggestion-insert-1", + action: "insert", + author: "assistant", + authorType: "ai", + }, + }, + }, + { insert: " eat" }, + ], + }); + + const decorations = buildAIReviewPresentationDecorations({ + activeSessionId: "session-1", + editor, + sessions: [createInlineSession()], + suggestionPresentation: "final-text", + }); + const contextDecorations = decorations.filter( + (decoration) => + decoration.type === "inline" && + decoration.attributes?.["data-pen-ai-review-role"] === + "context", + ); + + expect(contextDecorations).toEqual([]); + }); + + it("resolves review state from the active inline session", () => { + expect( + resolveAIReviewPresentationState({ + activeGeneration: null, + activeSession: createInlineSession(), + hasSuggestions: true, + }), + ).toBe("user-reviewing"); + expect( + resolveAIReviewPresentationState({ + activeGeneration: null, + activeSession: null, + hasSuggestions: true, + }), + ).toBe("resolved"); + }); +}); diff --git a/packages/extensions/ai/src/__tests__/reviewPresentation.testHelpers.ts b/packages/extensions/ai/src/__tests__/reviewPresentation.testHelpers.ts new file mode 100644 index 0000000..048132e --- /dev/null +++ b/packages/extensions/ai/src/__tests__/reviewPresentation.testHelpers.ts @@ -0,0 +1,249 @@ +import type { Editor } from "@pen/types"; +import { buildAIReviewPresentationDecorations } from "../review/reviewPresentation"; +import type { AISession } from "../types"; + +export function readVirtualText( + decorations: ReadonlyArray< + ReturnType[number] + >, +): string { + return decorations + .map((decoration) => + decoration.type === "inline" + ? ((decoration as typeof decoration & { virtualText?: string }) + .virtualText ?? "") + : "", + ) + .join(""); +} + +export function readDecorationAttributes( + decoration: + | ReturnType[number] + | undefined, +): Record | undefined { + return decoration && "attributes" in decoration + ? (decoration.attributes as Record) + : undefined; +} + +export function createReviewEditor({ + blockId, + deltas, + text, +}: { + blockId: string; + deltas: Array<{ insert: string; attributes?: Record }>; + text: string; +}): Editor { + return createReviewEditorFromBlocks([{ id: blockId, text, deltas }]); +} + +export function createReviewEditorFromBlocks( + blocks: Array<{ + id: string; + deltas: Array<{ insert: string; attributes?: Record }>; + text: string; + }>, +): Editor { + const editorBlocks = blocks.map((block) => ({ + id: block.id, + meta: () => null, + textContent: () => block.text, + })); + return { + documentState: { + allBlocks: () => editorBlocks, + }, + getBlock: (id: string) => + editorBlocks.find((block) => block.id === id) ?? null, + internals: { + getBlockText: (id: string) => { + const block = blocks.find((candidate) => candidate.id === id); + return block + ? { + toDelta: () => block.deltas, + } + : null; + }, + }, + } as unknown as Editor; +} + +export const MACBOOK_REPLACEMENT_ORIGINAL_TEXT = [ + "Hey Oleksandr,", + "", + "Sure thing— I'll have a MacBook ready for you, but feel free to bring your own setup if you prefer. See you a bit earlier, and let me know if you need anything else before then.", + "", + "- Krijn", +].join("\n"); + +export const MACBOOK_REPLACEMENT_PARAGRAPH_TEXT = + "Sure thing— I'll have a MacBook ready for you, but feel free to bring your own setup if you prefer."; + +export function buildMacBookProStreamingPreviewText( + originalText: string, +): string { + return originalText + .replace("MacBook ready", "MacBook Pro ready") + .slice(0, originalText.indexOf("ready") + "Pro ready".length); +} + +export const MACBOOK_THREE_BLOCK_PARTS = { + firstBlockText: "Hey Oleksandr,", + secondBlockText: + "Sure thing— I'll have a MacBook ready for you, but feel free to bring your own setup if you prefer.", + thirdBlockText: "- Krijn", +} as const; + +export function createMacBookThreeBlockReviewEditor(): Editor { + const { firstBlockText, secondBlockText, thirdBlockText } = + MACBOOK_THREE_BLOCK_PARTS; + return createReviewEditorFromBlocks([ + { + id: "body-1", + text: firstBlockText, + deltas: [{ insert: firstBlockText }], + }, + { + id: "body-2", + text: secondBlockText, + deltas: [{ insert: secondBlockText }], + }, + { + id: "body-3", + text: thirdBlockText, + deltas: [{ insert: thirdBlockText }], + }, + ]); +} + +export function createAlphaBetaGammaReviewEditor(): Editor { + return createReviewEditorFromBlocks([ + { + id: "body-1", + text: "Alpha", + deltas: [{ insert: "Alpha" }], + }, + { + id: "body-2", + text: "Beta", + deltas: [{ insert: "Beta" }], + }, + { + id: "body-3", + text: "Gamma", + deltas: [{ insert: "Gamma" }], + }, + ]); +} + +export function createHelloExclamationSuggestionEditor(): Editor { + return createReviewEditor({ + blockId: "body-1", + text: "Hello!", + deltas: [ + { insert: "Hello" }, + { + insert: "!", + attributes: { + suggestion: { + id: "suggestion-insert-1", + action: "insert", + author: "assistant", + authorType: "ai", + }, + }, + }, + ], + }); +} + +export const HI_TEXT_RANGE_STREAMING_PREVIEW = { + sessionId: "session-1", + target: { + kind: "text-range" as const, + blockId: "body-1", + from: 0, + to: 5, + }, + text: "Hi", + previousTextLength: 0, + revision: 1, + updatedAt: 123, +}; + +export function createAlphaBetaFriendReviewEditor(): Editor { + return createReviewEditorFromBlocks([ + { + id: "body-1", + text: "Alpha friend", + deltas: [{ insert: "Alpha friend" }], + }, + { + id: "body-2", + text: "Beta friend", + deltas: [{ insert: "Beta friend" }], + }, + ]); +} + +export function createInlineSession({ + focusOffset = 15, + pendingSuggestionIds = ["suggestion-insert-1"], +}: { + focusOffset?: number; + pendingSuggestionIds?: string[]; +} = {}): AISession { + return { + id: "session-1", + surface: "inline-edit", + status: "idle", + target: { + kind: "selection", + selection: { + anchor: { blockId: "body-1", offset: 0 }, + focus: { blockId: "body-1", offset: focusOffset }, + blockRange: ["body-1"], + isMultiBlock: false, + }, + }, + contextualPrompt: { + anchor: { + kind: "text-range", + focusBlockId: "body-1", + status: "valid", + lastResolvedRect: null, + selectionSnapshot: { + anchor: { blockId: "body-1", offset: 0 }, + focus: { blockId: "body-1", offset: focusOffset }, + blockRange: ["body-1"], + isMultiBlock: false, + }, + }, + composer: { + draftPrompt: "", + isOpen: true, + isSubmitting: false, + canSubmitFollowUp: true, + }, + }, + turns: [], + promptHistory: [], + generationIds: [], + pendingSuggestionIds, + pendingReviewItemIds: [], + createdAt: 0, + updatedAt: 0, + metrics: { + streamEventCount: 0, + fastApply: { + attemptCount: 0, + nativeFastApplyCount: 0, + scopedReplacementCount: 0, + plainMarkdownCount: 0, + failedCount: 0, + }, + }, + } as unknown as AISession; +} diff --git a/packages/extensions/ai/src/__tests__/textDiffOperations.test.ts b/packages/extensions/ai/src/__tests__/textDiffOperations.test.ts new file mode 100644 index 0000000..6a8ca64 --- /dev/null +++ b/packages/extensions/ai/src/__tests__/textDiffOperations.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from "vitest"; +import { + compileRangeReplacementSuggestionOps, + compileReplacementSuggestionOps, +} from "../suggestions/textDiffOperations"; + +describe("compileReplacementSuggestionOps", () => { + it("emits a word-level replacement for a single changed word", () => { + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 0, + originalText: "Thanks for joining us", + replacementText: "Thanks for meeting us", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 11, length: 7 }, + { + type: "insert-text", + blockId: "body-1", + offset: 18, + text: "meeting", + }, + ]); + }); + + it("emits phrase insertions without touching unchanged words", () => { + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 4, + originalText: "Sounds good", + replacementText: "Sounds really good", + }), + ).toEqual([ + { + type: "insert-text", + blockId: "body-1", + offset: 11, + text: "really ", + }, + ]); + }); + + it("emits phrase deletions without replacing the whole sentence", () => { + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 0, + originalText: "Sounds really good", + replacementText: "Sounds good", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 7, length: 7 }, + ]); + }); + + it("keeps punctuation changes precise", () => { + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 0, + originalText: "I can make it.", + replacementText: "I can't make it.", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 2, length: 3 }, + { + type: "insert-text", + blockId: "body-1", + offset: 5, + text: "can't", + }, + ]); + }); + + it("falls back to a coarse replace when the token window is too large", () => { + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 3, + originalText: "one two three", + replacementText: "four five six", + maxDiffCells: 1, + }), + ).toEqual([ + { + type: "replace-text", + blockId: "body-1", + offset: 3, + length: 13, + text: "four five six", + }, + ]); + }); + + it("falls back to a coarse replace when a long rewrite would produce noisy hunks", () => { + const originalText = + "I will set yet that up - I will spin up private repo on our side and share access so you can pull it straight into your internal toolingtools."; + const replacementText = + "I will set up a private repo for you and grant access so you can pull it directly into your internal tooling."; + + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 0, + originalText, + replacementText, + }), + ).toEqual([ + { + type: "replace-text", + blockId: "body-1", + offset: 0, + length: originalText.length, + text: replacementText, + }, + ]); + }); + + it("keeps single-word changes precise inside multiline selected text", () => { + const originalText = [ + "Hey Oleksandr,", + "", + "Sure thing— I'll have a MacBook ready for you, but feel free to bring your own setup if you prefer. See you a bit earlier, and let me know if you need anything else before then.", + "", + "- Krijn", + ].join("\n"); + const replacementText = originalText.replace( + "MacBook ready", + "MacBook Pro ready", + ); + + expect( + compileReplacementSuggestionOps({ + blockId: "body-1", + offset: 0, + originalText, + replacementText, + }), + ).toEqual([ + { + type: "insert-text", + blockId: "body-1", + offset: originalText.indexOf("ready"), + text: "Pro ", + }, + ]); + }); +}); + +describe("compileRangeReplacementSuggestionOps", () => { + it("splits newline-separated replacements into inserted paragraph blocks", () => { + let nextBlockIndex = 0; + expect( + compileRangeReplacementSuggestionOps({ + blocks: [{ id: "body-1", text: "Hello old text" }], + createBlockId: () => `new-block-${(nextBlockIndex += 1)}`, + range: { + start: { blockId: "body-1", offset: 6 }, + end: { blockId: "body-1", offset: 14 }, + }, + replacementText: "first paragraph\n\nsecond paragraph", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 10, length: 4 }, + { + type: "insert-text", + blockId: "body-1", + offset: 14, + text: "paragraph", + }, + { type: "delete-text", blockId: "body-1", offset: 6, length: 3 }, + { + type: "insert-text", + blockId: "body-1", + offset: 9, + text: "first", + }, + { + type: "insert-block", + blockId: "new-block-1", + blockType: "paragraph", + props: {}, + position: { after: "body-1" }, + }, + { + type: "insert-block", + blockId: "new-block-2", + blockType: "paragraph", + props: {}, + position: { after: "new-block-1" }, + }, + { + type: "insert-text", + blockId: "new-block-2", + offset: 0, + text: "second paragraph", + }, + ]); + }); + + it("preserves the end-block suffix for multi-block replacements", () => { + let nextBlockIndex = 0; + expect( + compileRangeReplacementSuggestionOps({ + blocks: [ + { id: "body-1", text: "Start selected" }, + { id: "body-2", text: "remove me" }, + { id: "body-3", text: "selection end" }, + ], + createBlockId: () => `new-block-${(nextBlockIndex += 1)}`, + range: { + start: { blockId: "body-1", offset: 6 }, + end: { blockId: "body-3", offset: 9 }, + }, + replacementText: "First\nSecond", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 6, length: 8 }, + { type: "delete-text", blockId: "body-3", offset: 0, length: 9 }, + { type: "delete-block", blockId: "body-2" }, + { + type: "insert-text", + blockId: "body-1", + offset: 6, + text: "First", + }, + { + type: "insert-block", + blockId: "new-block-1", + blockType: "paragraph", + props: {}, + position: { after: "body-1" }, + }, + { + type: "insert-text", + blockId: "new-block-1", + offset: 0, + text: "Second", + }, + { + type: "insert-text", + blockId: "new-block-1", + offset: 6, + text: " end", + }, + { type: "delete-block", blockId: "body-3" }, + ]); + }); + + it("keeps same-paragraph multi-block replacements word-level", () => { + expect( + compileRangeReplacementSuggestionOps({ + blocks: [ + { id: "body-1", text: "Thanks for joining us" }, + { id: "body-2", text: "I can meet tomorrow" }, + ], + range: { + start: { blockId: "body-1", offset: 0 }, + end: { blockId: "body-2", offset: "I can meet tomorrow".length }, + }, + replacementText: "Thanks for meeting us\nI can meet tomorrow", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 11, length: 7 }, + { + type: "insert-text", + blockId: "body-1", + offset: 18, + text: "meeting", + }, + ]); + }); + + it("normalizes reverse ranges before compiling replacements", () => { + expect( + compileRangeReplacementSuggestionOps({ + blocks: [{ id: "body-1", text: "Make this better" }], + range: { + start: { blockId: "body-1", offset: 16 }, + end: { blockId: "body-1", offset: 5 }, + }, + replacementText: "that stronger", + }), + ).toEqual([ + { type: "delete-text", blockId: "body-1", offset: 10, length: 6 }, + { + type: "insert-text", + blockId: "body-1", + offset: 16, + text: "stronger", + }, + { type: "delete-text", blockId: "body-1", offset: 5, length: 4 }, + { + type: "insert-text", + blockId: "body-1", + offset: 9, + text: "that", + }, + ]); + }); + + it("does not split same-block multiline replacements when only one word changes", () => { + const originalText = [ + "Hey Oleksandr,", + "", + "Sure thing— I'll have a MacBook ready for you, but feel free to bring your own setup if you prefer.", + "", + "- Krijn", + ].join("\n"); + const replacementText = originalText.replace( + "MacBook ready", + "MacBook Pro ready", + ); + + expect( + compileRangeReplacementSuggestionOps({ + blocks: [{ id: "body-1", text: originalText }], + range: { + start: { blockId: "body-1", offset: 0 }, + end: { blockId: "body-1", offset: originalText.length }, + }, + replacementText, + }), + ).toEqual([ + { + type: "insert-text", + blockId: "body-1", + offset: originalText.indexOf("ready"), + text: "Pro ", + }, + ]); + }); +}); diff --git a/packages/extensions/ai/src/agentic/loop.ts b/packages/extensions/ai/src/agentic/loop.ts index 33bc5ec..f8b1982 100644 --- a/packages/extensions/ai/src/agentic/loop.ts +++ b/packages/extensions/ai/src/agentic/loop.ts @@ -117,6 +117,10 @@ export async function runAgenticLoop( tools: availableTools, signal, requestMode: options.requestMode, + operation: options.operation ?? undefined, + sessionId: options.sessionId, + turnId: options.turnId, + generationId, }); const pendingToolCalls: Array<{ toolCallId: string; diff --git a/packages/extensions/ai/src/controllers.ts b/packages/extensions/ai/src/controllers.ts new file mode 100644 index 0000000..b86be98 --- /dev/null +++ b/packages/extensions/ai/src/controllers.ts @@ -0,0 +1,75 @@ +import type { + AIInlineHistoryController, + AIInlineHistoryDirection, + AIReviewController, + PersistentSuggestion, +} from "./types"; + +export class AIInlineHistoryService implements AIInlineHistoryController { + constructor( + private readonly handlers: { + canUndoInlineHistory: () => boolean; + canRedoInlineHistory: () => boolean; + canHandleShortcut: (direction: AIInlineHistoryDirection) => boolean; + handleShortcut: (direction: AIInlineHistoryDirection) => boolean; + undoInlineHistory: () => boolean; + redoInlineHistory: () => boolean; + }, + ) {} + + canUndoInlineHistory(): boolean { + return this.handlers.canUndoInlineHistory(); + } + + canRedoInlineHistory(): boolean { + return this.handlers.canRedoInlineHistory(); + } + + canHandleShortcut(direction: AIInlineHistoryDirection): boolean { + return this.handlers.canHandleShortcut(direction); + } + + handleShortcut(direction: AIInlineHistoryDirection): boolean { + return this.handlers.handleShortcut(direction); + } + + undoInlineHistory(): boolean { + return this.handlers.undoInlineHistory(); + } + + redoInlineHistory(): boolean { + return this.handlers.redoInlineHistory(); + } +} + +export class AIReviewService implements AIReviewController { + constructor( + private readonly handlers: { + getSuggestions: () => readonly PersistentSuggestion[]; + acceptSuggestion: (id: string) => boolean; + rejectSuggestion: (id: string) => boolean; + acceptAllSuggestions: () => void; + rejectAllSuggestions: () => void; + }, + ) {} + + getSuggestions(): readonly PersistentSuggestion[] { + return this.handlers.getSuggestions(); + } + + acceptSuggestion(id: string): boolean { + return this.handlers.acceptSuggestion(id); + } + + rejectSuggestion(id: string): boolean { + return this.handlers.rejectSuggestion(id); + } + + acceptAllSuggestions(): void { + this.handlers.acceptAllSuggestions(); + } + + rejectAllSuggestions(): void { + this.handlers.rejectAllSuggestions(); + } +} diff --git a/packages/extensions/ai/src/decorations/affectedRange.ts b/packages/extensions/ai/src/decorations/affectedRange.ts index e66b414..e5abfa9 100644 --- a/packages/extensions/ai/src/decorations/affectedRange.ts +++ b/packages/extensions/ai/src/decorations/affectedRange.ts @@ -1,11 +1,25 @@ import type { Decoration, Editor, InlineDecoration } from "@pen/types"; import type { AISession, AISessionSelectionSnapshot } from "../types"; +interface InlineRange { + from: number; + to: number; +} + +interface YTextLike { + toDelta(): Array<{ + insert: string | object; + attributes?: Record; + }>; +} + const AFFECTED_RANGE_CLASS = "pen-ai-affected-range"; const AFFECTED_RANGE_STYLE = [ - "background: color-mix(in srgb, #2563eb 26%, transparent)", - "box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.72), inset 0 -1px 0 rgba(147, 197, 253, 0.92)", - "border-radius: 4px", + "background: var(--pen-ai-affected-range-background, color-mix(in srgb, #2563eb 26%, transparent))", + "box-shadow: var(--pen-ai-affected-range-box-shadow, inset 0 0 0 1px rgba(96, 165, 250, 0.72), inset 0 -1px 0 rgba(147, 197, 253, 0.92))", + "border-radius: var(--pen-ai-affected-range-border-radius, 4px)", + "padding-block: var(--pen-ai-affected-range-padding-block, 0)", + "margin-block: var(--pen-ai-affected-range-margin-block, 0)", "box-decoration-break: clone", "-webkit-box-decoration-break: clone", ].join("; "); @@ -87,25 +101,74 @@ function buildSelectionSnapshotDecorations( if (to <= from) { continue; } - const decoration: InlineDecoration = { - type: "inline", - blockId, - from, - to, - key: `ai-affected-range:${blockId}:${from}:${to}`, - attributes: { - class: AFFECTED_RANGE_CLASS, - "data-ai-affected-range": "", - "data-ai-affected-range-session": "", - style: AFFECTED_RANGE_STYLE, - }, - }; - decorations.push(decoration); + const insertedSuggestionRanges = readInsertedSuggestionRanges(editor, blockId); + for (const range of subtractRanges({ from, to }, insertedSuggestionRanges)) { + const decoration: InlineDecoration = { + type: "inline", + blockId, + from: range.from, + to: range.to, + key: `ai-affected-range:${blockId}:${range.from}:${range.to}`, + attributes: { + class: AFFECTED_RANGE_CLASS, + "data-ai-affected-range": "", + "data-ai-affected-range-session": "", + style: AFFECTED_RANGE_STYLE, + }, + }; + decorations.push(decoration); + } } return decorations; } +function readInsertedSuggestionRanges( + editor: Editor, + blockId: string, +): InlineRange[] { + const ytext = editor.internals.getBlockText(blockId) as YTextLike | null; + if (!ytext || typeof ytext.toDelta !== "function") { + return []; + } + + const ranges: InlineRange[] = []; + let offset = 0; + for (const delta of ytext.toDelta()) { + const length = typeof delta.insert === "string" ? delta.insert.length : 1; + const suggestion = delta.attributes?.suggestion as + | Record + | undefined; + if (suggestion?.action === "insert") { + ranges.push({ from: offset, to: offset + length }); + } + offset += length; + } + + return ranges; +} + +function subtractRanges(range: InlineRange, excludedRanges: InlineRange[]): InlineRange[] { + let ranges = [range]; + for (const excludedRange of excludedRanges) { + ranges = ranges.flatMap((candidate) => + subtractRange(candidate, excludedRange), + ); + } + return ranges; +} + +function subtractRange(range: InlineRange, excludedRange: InlineRange): InlineRange[] { + if (excludedRange.to <= range.from || excludedRange.from >= range.to) { + return [range]; + } + + return [ + { from: range.from, to: Math.max(range.from, excludedRange.from) }, + { from: Math.min(range.to, excludedRange.to), to: range.to }, + ].filter((candidate) => candidate.to > candidate.from); +} + function resolveBoundaryOffset( selectionSnapshot: AISessionSelectionSnapshot, blockId: string, diff --git a/packages/extensions/ai/src/decorations/finalTextReview.ts b/packages/extensions/ai/src/decorations/finalTextReview.ts new file mode 100644 index 0000000..487235f --- /dev/null +++ b/packages/extensions/ai/src/decorations/finalTextReview.ts @@ -0,0 +1,16 @@ +import type { Decoration, Editor } from "@pen/types"; +import { + FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE, + buildAIReviewPresentationDecorations, +} from "../review/reviewPresentation"; + +export { FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE }; + +export function buildFinalTextReviewDecorations(editor: Editor): Decoration[] { + return buildAIReviewPresentationDecorations({ + editor, + sessions: [], + activeSessionId: null, + suggestionPresentation: "final-text", + }); +} diff --git a/packages/extensions/ai/src/decorations/trackChanges.ts b/packages/extensions/ai/src/decorations/trackChanges.ts index 9db7f95..1590253 100644 --- a/packages/extensions/ai/src/decorations/trackChanges.ts +++ b/packages/extensions/ai/src/decorations/trackChanges.ts @@ -1,69 +1,11 @@ -import type { - BlockDecoration, - Decoration, - Editor, - InlineDecoration, -} from "@pen/types"; -import { readBlockSuggestionMeta } from "../suggestions/persistent"; - -interface YTextLike { - toDelta(): Array<{ - insert: string | object; - attributes?: Record; - }>; -} +import type { Decoration, Editor } from "@pen/types"; +import { buildAIReviewPresentationDecorations } from "../review/reviewPresentation"; export function buildTrackChangesDecorations(editor: Editor): Decoration[] { - const decorations: Decoration[] = []; - - for (const block of editor.documentState.allBlocks()) { - const blockSuggestion = readBlockSuggestionMeta(block); - if (blockSuggestion) { - const blockDecoration: BlockDecoration = { - type: "block", - blockId: block.id, - attributes: { - class: `pen-block-suggestion pen-block-suggestion-${blockSuggestion.action}`, - "data-suggestion-id": blockSuggestion.id, - "data-suggestion-action": blockSuggestion.action, - "data-suggestion-author-type": blockSuggestion.authorType, - }, - }; - decorations.push(blockDecoration); - } - - const ytext = editor.internals.getBlockText(block.id) as YTextLike | null; - if (!ytext || typeof ytext.toDelta !== "function") { - continue; - } - - let offset = 0; - for (const delta of ytext.toDelta()) { - const length = typeof delta.insert === "string" ? delta.insert.length : 1; - const suggestion = delta.attributes?.suggestion as - | Record - | undefined; - if (suggestion && typeof suggestion.id === "string") { - const inlineDecoration: InlineDecoration = { - type: "inline", - blockId: block.id, - from: offset, - to: offset + length, - attributes: { - class: `pen-suggestion-${String(suggestion.action ?? "insert")}`, - "data-suggestion-id": suggestion.id, - "data-suggestion-action": String(suggestion.action ?? "insert"), - "data-suggestion-author": String(suggestion.author ?? ""), - "data-suggestion-author-type": String( - suggestion.authorType ?? "user", - ), - }, - }; - decorations.push(inlineDecoration); - } - offset += length; - } - } - - return decorations; + return buildAIReviewPresentationDecorations({ + editor, + sessions: [], + activeSessionId: null, + suggestionPresentation: "track-changes", + }); } diff --git a/packages/extensions/ai/src/extension.ts b/packages/extensions/ai/src/extension.ts index e860d1c..aa89102 100644 --- a/packages/extensions/ai/src/extension.ts +++ b/packages/extensions/ai/src/extension.ts @@ -3,22 +3,11 @@ import { ensureInlineCompletionController, getInlineCompletionController as getInlineCompletionControllerFromCore, } from "@pen/core"; -import { buildDocumentWriteOps, getDocumentToolRuntime } from "@pen/document-ops"; import type { - Decoration, - DocumentOp, Editor, Extension, - HistoryAppliedEvent, KeyBinding, ModelAdapter, - ModelOperationScopedRangeTarget, - ModelOperationSelectionTarget, - SelectionState, - StreamingTarget, - TextSelection, - ToolDefinition, - ToolRuntime, UndoHistoryMetadataController, } from "@pen/types"; import { @@ -28,69 +17,15 @@ import { AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, defineExtension, - isScopedSelectionTarget, - renderSelectionTargetBlockText, - resolveSelectionTargetBlockIds, - shouldExposeBlockInTooling, + getOpOriginType, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, - usesInlineTextSelection, } from "@pen/types"; -import { runAgenticLoop } from "./agentic/loop"; import { defaultAICommands } from "./commands/defaultCommands"; import { AICommandRegistry } from "./commands/registry"; -import { buildAffectedRangeDecorations } from "./decorations/affectedRange"; -import { buildGenerationZoneDecorations } from "./decorations/generationZone"; -import { buildTrackChangesDecorations } from "./decorations/trackChanges"; -import { getBlockAdapter } from "./runtime/blockAdapters"; -import type { - AIApplyStrategy, - AIContentFormat, - AITargetKind, -} from "./runtime/contracts"; -import { resolveDocumentInsertionAnchor } from "./runtime/documentInsertionAnchor"; -import { - MARKDOWN_FAST_APPLY_ROOT_TAG, - normalizeFlowMarkdownOutput, -} from "./runtime/flowMarkdown"; -import { - applyMarkdownFastApply, - parseMarkdownFastApplyContract, -} from "./runtime/markdownFastApply"; -import { parseMarkdownPatchPlanContract } from "./runtime/markdownPatchPlan"; -import { buildMutationReceipt } from "./runtime/mutationReceipt"; -import { buildDocumentMutationPlanExecution } from "./runtime/planExecutor"; -import { validateDocumentMutationPlanShape } from "./runtime/planValidation"; -import type { StructuralReviewItem } from "./runtime/reviewArtifacts"; -import { - buildStructuralReviewItems, - removeStructuralReviewItemPlan, - selectStructuralReviewItemPlan, -} from "./runtime/reviewArtifacts"; -import { - classifyPromptIntent, - refineRouteWithNavigator, - routeAIRequest, -} from "./runtime/router"; -import { compileStructuredIntentToPlan } from "./runtime/structuredIntentCompiler"; -import { - buildPlannerPrompt, - parseStructuredPlanPreview, - parseStructuredPlanResult, - resolveExecutionMode, -} from "./runtime/structuredPlanner"; -import { - buildGenerationStructuredPreviewState, - buildStructuredPreviewPatchOperations, -} from "./runtime/structuredPreview"; -import { - acceptAllSuggestions, - acceptSuggestion, - acceptSuggestions, - rejectAllSuggestions, - rejectSuggestion, - rejectSuggestions, -} from "./suggestions/acceptReject"; -import { readAllSuggestions } from "./suggestions/persistent"; +import { AIInlineHistoryService, AIReviewService } from "./controllers"; +import type { AIContentFormat } from "./runtime/contracts"; +import { SuggestedAIOperationRunner } from "./runtime/suggestedOperationRunner"; +import { ExternalInlineTurnRegistry } from "./runtime/externalInlineTurnRegistry"; import { AI_SESSION_SUGGESTION_ORIGIN, interceptApplyForSuggestMode, @@ -105,36 +40,48 @@ import type { AIController, AIControllerState, AIExtensionConfig, + AIExternalInlineTurnResult, AIInlineCompletionController, AIInlineHistoryController, AIInlineHistoryDirection, AIInlineHistorySnapshot, - AIMutationReceipt, AIReviewController, - AIRequestedOperation, - AISession, - AISessionMetrics, - AISessionResolution, - AISessionSelectionSnapshot, - AISessionTarget, AIStreamEvent, - AISurface, - AIWorkingSetEnvelope, - AIWorkingSetRetrievedSpan, - FastApplyDebugState, - GenerationState, - GenerationStructuredPreviewState, - PersistentTextSuggestion, PersistentSuggestion, - ResolvedEditProposal, - ResolvedEditTarget, } from "./types"; +import { aiControllerMethodsPart1 } from "./extensionParts/aiControllerMethodsPart1"; +import { aiControllerMethodsPart2 } from "./extensionParts/aiControllerMethodsPart2"; +import { aiControllerMethodsPart3 } from "./extensionParts/aiControllerMethodsPart3"; +import { aiControllerMethodsPart4 } from "./extensionParts/aiControllerMethodsPart4"; +import { aiControllerMethodsPart5 } from "./extensionParts/aiControllerMethodsPart5"; +import { aiControllerMethodsPart6 } from "./extensionParts/aiControllerMethodsPart6"; +import { aiControllerMethodsPart7 } from "./extensionParts/aiControllerMethodsPart7"; +import { aiControllerMethodsPart8 } from "./extensionParts/aiControllerMethodsPart8"; +import { aiControllerMethodsPart9 } from "./extensionParts/aiControllerMethodsPart9"; +import { aiControllerMethodsPart10 } from "./extensionParts/aiControllerMethodsPart10"; +import { aiControllerMethodsPart11 } from "./extensionParts/aiControllerMethodsPart11"; +import { aiControllerMethodsPart12 } from "./extensionParts/aiControllerMethodsPart12"; +import { aiControllerMethodsPart13 } from "./extensionParts/aiControllerMethodsPart13"; +import { aiControllerMethodsPart14 } from "./extensionParts/aiControllerMethodsPart14"; +import { aiControllerMethodsPart15 } from "./extensionParts/aiControllerMethodsPart15"; +import { aiControllerMethodsPart16 } from "./extensionParts/aiControllerMethodsPart16"; +import { + AI_UNDO_HISTORY_METADATA_KEY, + createDefaultSessionFastApplyMetrics, + readModelId, +} from "./extensionParts/extensionHelpers"; +import type { AIInlineHistoryRestoreRequest } from "./extensionParts/extensionHelpers"; export const AI_EXTENSION_NAME = "ai"; + export const AI_CONTROLLER_SLOT = CORE_AI_CONTROLLER_SLOT; + export const INLINE_COMPLETION_SLOT = CORE_INLINE_COMPLETION_SLOT; + export const AI_INLINE_COMPLETION_SLOT = INLINE_COMPLETION_SLOT; + export const AI_INLINE_HISTORY_SLOT = CORE_AI_INLINE_HISTORY_SLOT; + export const AI_REVIEW_CONTROLLER_SLOT = CORE_AI_REVIEW_CONTROLLER_SLOT; const AI_SHORTCUT_KEY_BINDINGS: readonly KeyBinding[] = [ @@ -176,216 +123,74 @@ const AI_SHORTCUT_KEY_BINDINGS: readonly KeyBinding[] = [ }, ]; -type GenerationTarget = - | { - type: "block"; - blockId: string; - offset: number; - } - | { - type: "selection"; - selection: TextSelection; - }; - -interface GenerationExecutionContext { - sessionId?: string; - surface?: AISurface; - targetType?: GenerationTarget["type"]; - operation?: AIRequestedOperation | null; - replaceTargetBlock?: boolean; - replaceBlockIds?: string[]; -} - -function resolveGenerationRequestMode( - context?: GenerationExecutionContext, -): string | undefined { - if (context?.operation?.kind === "rewrite-selection") { - if (context.surface === "inline-edit") { - return "inline-edit"; - } - if (context.surface === "bottom-chat") { - return "selection-fast"; - } - } - if (context?.targetType === "selection") { - if (context.surface === "inline-edit") { - return "inline-edit"; - } - if (context.surface === "bottom-chat") { - return "selection-fast"; - } - } - if (context?.surface === "inline-edit") { - return "inline-edit"; - } - if (context?.surface === "bottom-chat") { - return "bottom-chat"; - } - return undefined; -} - -function isLocalRequestedOperation( - operation: AIRequestedOperation | null | undefined, -): operation is AIRequestedOperation { - return ( - operation?.kind === "rewrite-selection" || - operation?.kind === "rewrite-block" || - operation?.kind === "continue-block" || - ( - operation?.kind === "document-transform" && - operation.target.kind === "document" && - ( - operation.target.transform === "rewrite" || - operation.target.transform === "remove" || - operation.target.placement === "replace-blocks" - ) - ) - ); -} - -const EMPTY_TOOL_RUNTIME: ToolRuntime = { - registerTool(_def: ToolDefinition): void { }, - unregisterTool(_name: string): void { }, - listTools(): readonly ToolDefinition[] { - return []; - }, - getTool(): ToolDefinition | null { - return null; - }, - async executeTool(name: string): Promise { - throw new Error(`Unknown tool: "${name}"`); - }, -}; - -const MAX_STREAM_EVENTS = 200; -const AI_UNDO_HISTORY_METADATA_KEY = "ai:inline-session-history"; - -interface AIInlineHistoryRestoreRequest { - direction: AIInlineHistoryDirection; - targetSnapshotId: string; - targetDocumentVersion: number; - shortcutOnly?: boolean; - sessionId?: string | null; - targetState?: AIInlineShortcutHistoryState | null; -} - -type AIInlineShortcutHistoryPhase = "none" | "review" | "resolved"; - -interface AIInlineShortcutHistoryState { - sessionId: string | null; - phase: AIInlineShortcutHistoryPhase; - turnCount: number; - turnId: string | null; - resolution?: "accepted" | "rejected"; -} - -interface AIInlineShortcutHistoryWaypoint { - startIndex: number; - endIndex: number; - representativeIndex: number; - state: AIInlineShortcutHistoryState; -} - -class AIInlineHistoryService implements AIInlineHistoryController { - constructor( - private readonly _handlers: { - canUndoInlineHistory: () => boolean; - canRedoInlineHistory: () => boolean; - canHandleShortcut: (direction: AIInlineHistoryDirection) => boolean; - handleShortcut: (direction: AIInlineHistoryDirection) => boolean; - undoInlineHistory: () => boolean; - redoInlineHistory: () => boolean; - }, - ) { } - - canUndoInlineHistory(): boolean { - return this._handlers.canUndoInlineHistory(); - } - - canRedoInlineHistory(): boolean { - return this._handlers.canRedoInlineHistory(); - } +class AIControllerImpl { + private readonly _editor: Editor; - canHandleShortcut(direction: AIInlineHistoryDirection): boolean { - return this._handlers.canHandleShortcut(direction); - } + private readonly _registry = new AICommandRegistry(); - handleShortcut(direction: AIInlineHistoryDirection): boolean { - return this._handlers.handleShortcut(direction); - } + private readonly _inlineCompletion: AIInlineCompletionController; - undoInlineHistory(): boolean { - return this._handlers.undoInlineHistory(); - } + private readonly _listeners = new Set<() => void>(); - redoInlineHistory(): boolean { - return this._handlers.redoInlineHistory(); - } -} + private readonly _sessionListeners = new Set<() => void>(); -class AIReviewService implements AIReviewController { - constructor( - private readonly _handlers: { - getSuggestions: () => readonly PersistentSuggestion[]; - acceptSuggestion: (id: string) => boolean; - rejectSuggestion: (id: string) => boolean; - acceptAllSuggestions: () => void; - rejectAllSuggestions: () => void; - }, - ) { } + private readonly _streamEventListeners = new Set<() => void>(); - getSuggestions(): readonly PersistentSuggestion[] { - return this._handlers.getSuggestions(); - } + private readonly _model: ModelAdapter | undefined; - acceptSuggestion(id: string): boolean { - return this._handlers.acceptSuggestion(id); - } + private readonly _author: string; - rejectSuggestion(id: string): boolean { - return this._handlers.rejectSuggestion(id); - } + private readonly _suggestedOperationRunner: SuggestedAIOperationRunner; - acceptAllSuggestions(): void { - this._handlers.acceptAllSuggestions(); - } + private readonly _maxAgenticSteps: number; - rejectAllSuggestions(): void { - this._handlers.rejectAllSuggestions(); - } -} + private readonly _suggestionPresentation: NonNullable< + AIExtensionConfig["suggestionPresentation"] + >; -class AIControllerImpl implements AIController { - private readonly _editor: Editor; - private readonly _registry = new AICommandRegistry(); - private readonly _inlineCompletion: AIInlineCompletionController; - private readonly _listeners = new Set<() => void>(); - private readonly _sessionListeners = new Set<() => void>(); - private readonly _streamEventListeners = new Set<() => void>(); - private readonly _model: ModelAdapter | undefined; - private readonly _author: string; - private readonly _maxAgenticSteps: number; private readonly _contentFormat: { blockGeneration: AIContentFormat; selectionRewrite: AIContentFormat; }; + private _state: AIControllerState; + private _suggestions: readonly PersistentSuggestion[] = []; + private _streamEvents: readonly AIStreamEvent[] = []; + private _abortController: AbortController | null = null; + private _lastPrompt: string | null = null; + private _lastCommandId: string | null = null; + private _documentVersion = 0; + private _unsubscribeHistoryApplied: (() => void) | null = null; + private _unsubscribeInlineCompletion: (() => void) | null = null; + private _unsubscribeUndoHistoryMetadata: (() => void) | null = null; + private readonly _undoHistoryMetadata: UndoHistoryMetadataController | null; + private _inlineHistory: AIInlineHistorySnapshot[] = []; + private _inlineHistoryIndex = -1; - private _pendingInlineHistoryRestore: AIInlineHistoryRestoreRequest | null = null; - private _queuedInlineHistoryShortcutDirections: AIInlineHistoryDirection[] = []; + + private _externalInlineTurnRegistry = new ExternalInlineTurnRegistry(); + + private _pendingInlineHistoryRestore: AIInlineHistoryRestoreRequest | null = + null; + + private _queuedInlineHistoryShortcutDirections: AIInlineHistoryDirection[] = + []; + private _queuedInlineHistoryShortcutFlushScheduled = false; + private _isRestoringInlineHistory = false; + private _handledUndoHistoryRequestId: number | null = null; constructor( @@ -399,7 +204,19 @@ class AIControllerImpl implements AIController { this._inlineCompletion = services.inlineCompletion; this._model = config.model; this._author = config.author ?? "assistant"; + this._suggestedOperationRunner = new SuggestedAIOperationRunner({ + editor: this._editor, + author: this._author, + model: readModelId(this._model), + getSession: (sessionId) => + this._state.sessions.find( + (session) => session.id === sessionId, + ) ?? null, + getActiveGeneration: () => this._state.activeGeneration, + }); this._maxAgenticSteps = config.maxAgenticSteps ?? 10; + this._suggestionPresentation = + config.suggestionPresentation ?? "track-changes"; this._contentFormat = { blockGeneration: config.contentFormat?.blockGeneration ?? "text", selectionRewrite: config.contentFormat?.selectionRewrite ?? "text", @@ -415,6 +232,7 @@ class AIControllerImpl implements AIController { activeSessionId: null, suggestMode: config.suggestMode ?? false, ephemeralSuggestion: null, + streamingReviewPreview: null, commandMenuOpen: false, }; @@ -427,14 +245,19 @@ class AIControllerImpl implements AIController { this._syncSuggestionsFromDocument(); - this._unsubscribeInlineCompletion = this._inlineCompletion.subscribe(() => { - this._setState({ - ephemeralSuggestion: this._inlineCompletion.getState().visibleSuggestion, - }); - }); - this._unsubscribeHistoryApplied = this._editor.onHistoryApplied((event) => { - this._handleHistoryApplied(event); - }); + this._unsubscribeInlineCompletion = this._inlineCompletion.subscribe( + () => { + this._setState({ + ephemeralSuggestion: + this._inlineCompletion.getState().visibleSuggestion, + }); + }, + ); + this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( + (event) => { + this._handleHistoryApplied(event); + }, + ); this._unsubscribeUndoHistoryMetadata = this._undoHistoryMetadata?.registerMetadataRestorer( AI_UNDO_HISTORY_METADATA_KEY, @@ -447,7819 +270,186 @@ class AIControllerImpl implements AIController { }, ) ?? null; } +} - destroy(): void { - this._unsubscribeInlineCompletion?.(); - this._unsubscribeInlineCompletion = null; - this._unsubscribeHistoryApplied?.(); - this._unsubscribeHistoryApplied = null; - this._unsubscribeUndoHistoryMetadata?.(); - this._unsubscribeUndoHistoryMetadata = null; - } +interface AIControllerImpl extends AIController {} + +Object.assign( + AIControllerImpl.prototype, + aiControllerMethodsPart1, + aiControllerMethodsPart2, + aiControllerMethodsPart3, + aiControllerMethodsPart4, + aiControllerMethodsPart5, + aiControllerMethodsPart6, + aiControllerMethodsPart7, + aiControllerMethodsPart8, + aiControllerMethodsPart9, + aiControllerMethodsPart10, + aiControllerMethodsPart11, + aiControllerMethodsPart12, + aiControllerMethodsPart13, + aiControllerMethodsPart14, + aiControllerMethodsPart15, + aiControllerMethodsPart16, +); - getState(): AIControllerState { - return this._state; - } +export function aiExtension(config: AIExtensionConfig = {}): Extension { + let unsubscribeBeforeApply: (() => void) | null = null; + let unsubscribeTrackedOrigins: (() => void) | null = null; + let controller: AIControllerImpl | null = null; + let inlineCompletion: AIInlineCompletionController | null = null; + let releaseInlineCompletion: (() => void) | null = null; + let inlineHistory: AIInlineHistoryService | null = null; + let reviewController: AIReviewService | null = null; + let activeEditor: Editor | null = null; - subscribe(listener: () => void): () => void { - this._listeners.add(listener); - return () => this._listeners.delete(listener); - } + return defineExtension({ + name: AI_EXTENSION_NAME, + dependencies: ["document-ops", "delta-stream", "undo"], + keyBindings: AI_SHORTCUT_KEY_BINDINGS, - getSessions(): readonly AISession[] { - return this._state.sessions; - } + activateClient: async ({ editor }) => { + activeEditor = editor; + const inlineCompletionRegistration = + ensureInlineCompletionController(editor); + inlineCompletion = inlineCompletionRegistration.controller; + releaseInlineCompletion = inlineCompletionRegistration.release; + controller = new AIControllerImpl(editor, config, { + inlineCompletion, + }); + inlineHistory = new AIInlineHistoryService({ + canUndoInlineHistory: () => + controller ? controller.canUndoInlineHistory() : false, + canRedoInlineHistory: () => + controller ? controller.canRedoInlineHistory() : false, + canHandleShortcut: (direction) => + controller + ? controller.canHandleInlineHistoryShortcut(direction) + : false, + handleShortcut: (direction) => + controller + ? controller.handleInlineHistoryShortcut(direction) + : false, + undoInlineHistory: () => + controller ? controller.undoInlineHistory() : false, + redoInlineHistory: () => + controller ? controller.redoInlineHistory() : false, + }); + reviewController = new AIReviewService({ + getSuggestions: () => controller?.getSuggestions() ?? [], + acceptSuggestion: (id) => + controller?.acceptSuggestion(id) ?? false, + rejectSuggestion: (id) => + controller?.rejectSuggestion(id) ?? false, + acceptAllSuggestions: () => controller?.acceptAllSuggestions(), + rejectAllSuggestions: () => controller?.rejectAllSuggestions(), + }); + editor.internals.setSlot(AI_CONTROLLER_SLOT, controller); + editor.internals.setSlot(AI_INLINE_HISTORY_SLOT, inlineHistory); + editor.internals.setSlot( + AI_REVIEW_CONTROLLER_SLOT, + reviewController, + ); + unsubscribeTrackedOrigins = + editor.undoManager.registerTrackedOrigins([ + AI_SESSION_SUGGESTION_ORIGIN, + SUGGESTION_RESOLUTION_ORIGIN, + ]); - getActiveSession(): AISession | null { - const activeSessionId = this._state.activeSessionId; - if (!activeSessionId) { - return null; - } - return this._state.sessions.find((session) => session.id === activeSessionId) ?? null; - } + unsubscribeBeforeApply = editor.onBeforeApply( + (ops, options) => { + if (!controller?.getState().suggestMode) return ops; + if (shouldBypassSuggestMode(options.origin)) return ops; + const originType = options.origin + ? getOpOriginType(options.origin) + : undefined; + return interceptApplyForSuggestMode( + ops, + editor, + originType === "ai" + ? "assistant" + : (config.author ?? "user"), + originType === "ai" ? "ai" : "user", + readModelId(config.model), + ); + }, + { priority: 200 }, + ); + }, - subscribeSessions(listener: () => void): () => void { - this._sessionListeners.add(listener); - return () => this._sessionListeners.delete(listener); - } + deactivateClient: async () => { + controller?.cancelActiveGeneration(); + controller?.destroy(); + activeEditor?.internals.setSlot(AI_CONTROLLER_SLOT, null); + activeEditor?.internals.setSlot(AI_INLINE_HISTORY_SLOT, null); + activeEditor?.internals.setSlot(AI_REVIEW_CONTROLLER_SLOT, null); + releaseInlineCompletion?.(); + unsubscribeTrackedOrigins?.(); + unsubscribeTrackedOrigins = null; + unsubscribeBeforeApply?.(); + unsubscribeBeforeApply = null; + controller = null; + inlineCompletion = null; + releaseInlineCompletion = null; + inlineHistory = null; + reviewController = null; + activeEditor = null; + }, - getStreamEvents(): readonly AIStreamEvent[] { - return this._streamEvents; - } + observe: (events, editor) => { + if (!controller) { + editor.requestDecorationUpdate(); + return; + } + controller.handleDocumentChange(events); + }, - subscribeStreamEvents(listener: () => void): () => void { - this._streamEventListeners.add(listener); - return () => this._streamEventListeners.delete(listener); - } + decorations: () => { + const decorations = controller?.buildDecorations() ?? []; + const inlineDecorations = + activeEditor?.internals.getSlot( + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + ) == null + ? (inlineCompletion?.buildDecorations() ?? []) + : []; + return createDecorationSet([...decorations, ...inlineDecorations]); + }, + }); +} - getCommands(): readonly AICommandBinding[] { - return this._registry.list(this.getCommandContext()); - } +export function getAIController(editor: Editor): AIController | null { + return editor.internals.getSlot(AI_CONTROLLER_SLOT) ?? null; +} - getCommandContext(): AICommandContext { - const selection = this._editor.selection; - const blockId = resolveActiveBlockId(selection); - return { - editor: this._editor, - selection, - selectedText: - selection?.type === "text" - ? resolveSelectionText(this._editor, selection) - : "", - blockType: blockId ? this._editor.getBlock(blockId)?.type ?? null : null, - blockId, - }; - } +export function getInlineCompletionController( + editor: Editor, +): AIInlineCompletionController | null { + return getInlineCompletionControllerFromCore(editor); +} - startSession(input: { - surface: AISurface; - target?: "auto" | "selection" | "block" | "document"; - }): AISession { - const now = Date.now(); - const target = resolveSessionTarget(this._editor, input.target); - const session: AISession = { - id: crypto.randomUUID(), - surface: input.surface, - status: "idle", - target, - contextualPrompt: - input.surface === "inline-edit" - ? resolveContextualPromptState(target) - : undefined, - turns: [], - activeTurnId: undefined, - promptHistory: [], - generationIds: [], - pendingSuggestionIds: [], - pendingReviewItemIds: [], - createdAt: now, - updatedAt: now, - metrics: { - streamEventCount: 0, - patchCount: 0, - fastApply: createDefaultSessionFastApplyMetrics(), - }, - anchor: resolveSessionAnchor(this._editor.selection), - }; - this._setState({ - sessions: [...this._state.sessions, session], - activeSessionId: session.id, - }); - return session; - } +export function getAIInlineCompletionController( + editor: Editor, +): AIInlineCompletionController | null { + return getInlineCompletionController(editor); +} - openContextualPrompt(input?: { - surface?: Extract; - target?: "auto" | "selection" | "block" | "document"; - }): AISession | null { - const surface = input?.surface ?? "inline-edit"; - const target = resolveSessionTarget(this._editor, input?.target ?? "selection"); - if (surface === "inline-edit" && target.kind !== "selection") { - return null; - } - const activeSession = this._state.sessions.find( - (session) => - session.id === this._state.activeSessionId && - session.surface === surface && - session.status !== "cancelled", - ); - if ( - activeSession && - activeSession.status !== "complete" && - sessionTargetMatches(activeSession, target) - ) { - this._updateSession(activeSession.id, { - target, - anchor: resolveSessionAnchor(this._editor.selection), - contextualPrompt: { - ...(activeSession.contextualPrompt ?? - resolveContextualPromptState(target)), - anchor: resolveContextualPromptAnchor(target), - composer: { - ...(activeSession.contextualPrompt?.composer ?? { - draftPrompt: "", - isSubmitting: false, - canSubmitFollowUp: true, - openReason: "user", - }), - isOpen: true, - openReason: "user", - }, - }, - }); - return this.getActiveSession(); - } - if (activeSession?.surface === "inline-edit") { - this._setInlineSessionComposerOpen(activeSession.id, false); - } - const nextSession = this.startSession({ - surface, - target: input?.target ?? "selection", - }); - return nextSession.contextualPrompt?.anchor.kind === "text-range" - ? nextSession - : null; - } +export function getAIInlineHistoryController( + editor: Editor, +): AIInlineHistoryController | null { + return ( + editor.internals.getSlot( + AI_INLINE_HISTORY_SLOT, + ) ?? null + ); +} - updateContextualPromptDraft(sessionId: string, draftPrompt: string): void { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session?.contextualPrompt) { - return; - } - this._updateSession(sessionId, { - contextualPrompt: { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - draftPrompt, - }, - }, - }); - } - - setContextualPromptAnchorRect( - sessionId: string, - rect: AIContextualPromptRect | null, - ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session?.contextualPrompt) { - return; - } - this._updateSession(sessionId, { - contextualPrompt: { - ...session.contextualPrompt, - anchor: { - ...session.contextualPrompt.anchor, - lastResolvedRect: rect, - }, - }, - }); - } - - resolveSessionTurn( - sessionId: string, - turnId: string, - resolution: AISessionResolution, - ): boolean { - return this._resolveSessionTurn(sessionId, turnId, resolution); - } - - acceptSessionTurn(sessionId: string, turnId: string): boolean { - return this.resolveSessionTurn(sessionId, turnId, "accept"); - } - - rejectSessionTurn(sessionId: string, turnId: string): boolean { - return this.resolveSessionTurn(sessionId, turnId, "reject"); - } - - runSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): Promise { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session) { - return Promise.reject(new Error(`Unknown AI session "${sessionId}"`)); - } - this._recordInlinePromptSubmissionCheckpoint(sessionId, prompt); - - const operation = - options?.operation ?? - resolveRequestedOperationForSession( - this._editor, - session, - prompt, - options, - this._documentVersion, - ); - if (operation.kind === "rewrite-selection") { - const selection = resolveSelectionForRequestedOperation(this._editor, operation); - if (!selection) { - return Promise.reject( - new Error("Cannot run a session prompt without a valid text selection"), - ); - } - return this._runSelectionGeneration(prompt, selection, undefined, options?.maxSteps, { - sessionId, - surface: session.surface, - operation, - }); - } - if (operation.kind === "document-transform") { - const targetBlockIds = - operation.target.kind === "document" && - (operation.target.blockIds?.length ?? 0) > 0 - ? [...(operation.target.blockIds ?? [])] - : undefined; - const replacePreviousGeneratedBlocks = shouldReplacePreviousGeneratedBlocks( - session, - prompt, - ); - return this._runDocumentGeneration( - prompt, - options?.blockId ?? - (operation.target.kind === "document" - ? operation.target.activeBlockId - : null), - undefined, - options?.maxSteps, - { - sessionId, - surface: session.surface, - operation, - replaceBlockIds: targetBlockIds ?? - (replacePreviousGeneratedBlocks - ? resolvePreviousGeneratedBlockIds(session) - : undefined), - }, - ); - } - const blockId = - options?.blockId ?? resolveBlockIdForRequestedOperation(operation) ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!blockId) { - return Promise.reject( - new Error("Cannot run an AI session prompt without a target block"), - ); - } - return this._runBlockGeneration( - prompt, - blockId, - undefined, - options?.maxSteps, - { - sessionId, - surface: session.surface, - operation, - }, - ); - } - - canReuseSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): boolean { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session) { - return false; - } - if (session.surface !== "bottom-chat" || !session.operation) { - return true; - } - const nextOperation = - options?.operation ?? - resolveRequestedOperationForSession( - this._editor, - session, - prompt, - options, - this._documentVersion, - ); - return canReuseBottomChatSessionOperation(session.operation, nextOperation); - } - - resolveSession(sessionId: string, resolution: AISessionResolution): boolean { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session) { - return false; - } - let resolved = false; - for (const turn of session.turns) { - resolved = - this._resolveSessionTurn(sessionId, turn.id, resolution, { - finalizeSession: false, - }) || resolved; - } - if (resolved) { - const nextSession = - this._state.sessions.find((item) => item.id === sessionId) ?? session; - this._updateSession(sessionId, { - status: "complete", - pendingSuggestionIds: [], - pendingReviewItemIds: [], - contextualPrompt: closeInlineSessionPrompt(nextSession), - }); - } - return resolved; - } - - acceptSession(sessionId: string): boolean { - return this.resolveSession(sessionId, "accept"); - } - - rejectSession(sessionId: string): boolean { - return this.resolveSession(sessionId, "reject"); - } - - cancelSession(sessionId: string): void { - if (this._state.activeGeneration?.sessionId === sessionId) { - this.cancelActiveGeneration(); - } - const session = this._state.sessions.find((item) => item.id === sessionId); - this._updateSession(sessionId, { - status: "cancelled", - contextualPrompt: session?.contextualPrompt - ? { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: false, - isSubmitting: false, - }, - } - : undefined, - }); - } - - suspendInlineSession(sessionId: string): void { - this._setInlineSessionComposerOpen(sessionId, false); - } - - resumeInlineSession(sessionId: string): void { - this._setInlineSessionComposerOpen(sessionId, true, { - openReason: "user", - }); - } - - canUndoInlineHistory(): boolean { - return this._inlineHistoryIndex > 0; - } - - canRedoInlineHistory(): boolean { - return this._inlineHistoryIndex >= 0 && this._inlineHistoryIndex < this._inlineHistory.length - 1; - } - - undoInlineHistory(): boolean { - return this._navigateInlineHistory("undo"); - } - - redoInlineHistory(): boolean { - return this._navigateInlineHistory("redo"); - } - - canHandleInlineHistoryShortcut( - direction: AIInlineHistoryDirection, - ): boolean { - if (this._pendingInlineHistoryRestore) { - return true; - } - return this._canHandleInlineHistoryShortcut(direction, { shortcutOnly: true }); - } - - handleInlineHistoryShortcut( - direction: AIInlineHistoryDirection, - ): boolean { - if (this._pendingInlineHistoryRestore) { - this._queuedInlineHistoryShortcutDirections.push(direction); - return true; - } - return this._navigateInlineHistory(direction, { shortcutOnly: true }); - } - - async runCommand( - commandId: string, - options?: AICommandExecutionOptions, - ): Promise { - const ctx = this.getCommandContext(); - const command = this._registry.resolve(commandId); - if (!command) { - throw new Error(`Unknown AI command "${commandId}"`); - } - if (command.guard && !command.guard(ctx)) { - throw new Error(`AI command "${command.label}" is not available in this context`); - } - - const prompt = this._registry.resolvePrompt(command, ctx); - this._lastPrompt = prompt; - this._lastCommandId = command.id; - - if (command.target === "selection" && ctx.selection?.type === "text" && !ctx.selection.isCollapsed) { - return this._runSelectionGeneration(prompt, ctx.selection, command.id, options?.maxSteps); - } - - const targetBlockId = - options?.blockId ?? - ctx.blockId ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!targetBlockId) { - throw new Error("Cannot run AI command without a target block"); - } - return this._runBlockGeneration(prompt, targetBlockId, command.id, options?.maxSteps); - } - - async runPrompt( - prompt: string, - options?: AICommandExecutionOptions, - ): Promise { - this._lastPrompt = prompt; - this._lastCommandId = null; - const promptTarget = resolvePromptTarget(this._editor.selection, options?.target); - if (promptTarget === "selection") { - const selection = this._editor.selection; - if (selection?.type !== "text" || selection.isCollapsed) { - throw new Error("Cannot run a selection prompt without selected text"); - } - return this._runSelectionGeneration(prompt, selection, undefined, options?.maxSteps); - } - if (promptTarget === "document") { - return this._runDocumentGeneration( - prompt, - options?.blockId, - undefined, - options?.maxSteps, - ); - } - const blockId = - options?.blockId ?? - resolveActiveBlockId(this._editor.selection) ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!blockId) { - throw new Error("Cannot run AI prompt without a target block"); - } - return this._runBlockGeneration(prompt, blockId, undefined, options?.maxSteps); - } - - async retryActiveGeneration(): Promise { - const prompt = this._lastPrompt; - if (!prompt) return null; - this.rejectActiveGeneration(); - const active = this._state.activeGeneration; - const blockId = - active?.blockId ?? - resolveActiveBlockId(this._editor.selection) ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id; - if (!blockId) return null; - if (active?.sessionId) { - const activeSession = this._state.sessions.find( - (session) => session.id === active.sessionId, - ); - const retryTarget = - activeSession?.target.kind === "document" - ? "document" - : active?.target ?? "block"; - return this.runSessionPrompt(active.sessionId, prompt, { - blockId: retryTarget === "document" ? null : blockId, - target: retryTarget, - }); - } - if (this._lastCommandId) { - return this.runCommand(this._lastCommandId, { blockId }); - } - return this.runPrompt(prompt, { - blockId, - target: active?.target ?? "block", - }); - } - - acceptActiveGeneration(): boolean { - const generation = this._state.activeGeneration; - if (!generation) { - return false; - } - - if (generation.suggestionIds && generation.suggestionIds.length > 0) { - const existingSession = - generation.sessionId != null - ? (this._state.sessions.find( - (session) => session.id === generation.sessionId, - ) ?? null) - : null; - const existingTurn = - generation.turnId != null - ? (existingSession?.turns.find((turn) => turn.id === generation.turnId) ?? null) - : null; - const refreshSuggestionIds = - existingTurn?.suggestionIds.length - ? existingTurn.suggestionIds - : generation.suggestionIds; - const refreshedInlineSelectionTarget = - generation.surface === "inline-edit" - ? resolveAcceptedInlineSelectionTarget( - this._editor, - existingTurn?.operation ?? generation.operation ?? undefined, - refreshSuggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor) - : null; - const accepted = acceptSuggestions(this._editor, generation.suggestionIds); - if (accepted) { - this._resolveActiveGeneration({ - suggestionIds: [], - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "accepted", - suggestionIds: [], - structuredPreview: null, - anchor: refreshedInlineSelectionTarget - ? resolveSessionAnchor(refreshedInlineSelectionTarget.selection) - : undefined, - selection: refreshedInlineSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineSelectionTarget.selection, - ) - : undefined, - }); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingSuggestionIds: [], - ...(refreshedInlineSelectionTarget - ? { - target: refreshedInlineSelectionTarget, - anchor: resolveSessionAnchor( - refreshedInlineSelectionTarget.selection, - ), - contextualPrompt: existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: resolveContextualPromptAnchor( - refreshedInlineSelectionTarget, - ), - } - : undefined, - } - : {}), - }); - } - } - return accepted; - } - - if (generation.planState !== "validated" || !generation.plan) { - return false; - } - - const execution = buildDocumentMutationPlanExecution( - this._editor, - generation.plan, - ); - if (execution.issues.length > 0) { - this._resolveActiveGeneration({ - planState: "rejected", - }); - return false; - } - - this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); - this._resolveActiveGeneration({ - planState: "none", - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "accepted", - reviewItemIds: [], - structuredPreview: null, - }); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingReviewItemIds: [], - }); - } - return true; - } - - rejectActiveGeneration(): boolean { - const generation = this._state.activeGeneration; - if (!generation) return false; - - if (generation.suggestionIds && generation.suggestionIds.length > 0) { - const rejected = rejectSuggestions(this._editor, generation.suggestionIds); - if (rejected) { - this._resolveActiveGeneration({ - suggestionIds: [], - planState: "rejected", - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "rejected", - suggestionIds: [], - structuredPreview: null, - }); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingSuggestionIds: [], - }); - } - } - return rejected; - } - - if (generation.planState === "validated" && generation.plan) { - this._resolveActiveGeneration({ - status: "cancelled", - planState: "rejected", - structuredPreview: null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: "rejected", - reviewItemIds: [], - structuredPreview: null, - }); - } - this._updateSession(generation.sessionId, { - status: "complete", - pendingReviewItemIds: [], - }); - } - return true; - } - - if (generation.status === "streaming") { - this.cancelActiveGeneration(); - } - - return this._editor.undoManager.undo(); - } - - acceptReviewItem(id: string): boolean { - return this.acceptReviewItems([id]); - } - - rejectReviewItem(id: string): boolean { - return this.rejectReviewItems([id]); - } - - acceptReviewItems(ids: readonly string[]): boolean { - return this._applyReviewItems(ids, "accept"); - } - - rejectReviewItems(ids: readonly string[]): boolean { - return this._applyReviewItems(ids, "reject"); - } - - private _applyReviewItems( - ids: readonly string[], - action: "accept" | "reject", - ): boolean { - const generation = this._state.activeGeneration; - if ( - !generation || - generation.planState !== "validated" || - !generation.plan || - !generation.reviewItems - ) { - return false; - } - - const reviewItems = resolveOrderedReviewItems(generation.reviewItems, ids); - if (reviewItems.length === 0) { - return false; - } - - if (action === "accept") { - const selectedPlans = reviewItems.map((reviewItem) => - selectStructuralReviewItemPlan(generation.plan!, reviewItem), - ); - if (selectedPlans.some((plan) => !plan)) { - return false; - } - const resolvedSelectedPlans = selectedPlans.filter( - ( - plan, - ): plan is NonNullable<(typeof selectedPlans)[number]> => plan != null, - ); - - const selectedPlan = - resolvedSelectedPlans.length === 1 - ? resolvedSelectedPlans[0]! - : { - kind: "review_bundle" as const, - label: "Bulk review selection", - reason: "Apply selected review items together.", - plans: resolvedSelectedPlans, - }; - const execution = buildDocumentMutationPlanExecution( - this._editor, - selectedPlan, - ); - if (execution.issues.length > 0) { - return false; - } - - this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); - } - - let nextPlan: GenerationState["plan"] = generation.plan; - for (const reviewItem of sortReviewItemsForRemoval(reviewItems)) { - if (!nextPlan) { - break; - } - nextPlan = removeStructuralReviewItemPlan(nextPlan, reviewItem); - } - const nextReviewItems = nextPlan - ? buildStructuralReviewItems(this._editor, nextPlan) - : []; - this._resolveActiveGeneration({ - status: - nextPlan || action === "accept" ? generation.status : "cancelled", - planState: nextPlan ? "validated" : action === "accept" ? "none" : "rejected", - plan: nextPlan, - reviewItems: nextReviewItems, - structuredPreview: nextPlan - ? buildGenerationStructuredPreviewState(this._editor, { - planState: "validated", - plan: nextPlan, - }) - : null, - }); - if (generation.sessionId) { - if (generation.turnId) { - this._updateSessionTurn(generation.sessionId, generation.turnId, { - status: nextPlan ? "review" : action === "accept" ? "accepted" : "rejected", - reviewItemIds: nextReviewItems.map((item) => item.id), - }); - } - this._updateSession(generation.sessionId, { - status: - nextPlan || action === "accept" - ? generation.status === "streaming" - ? "streaming" - : "complete" - : "complete", - pendingReviewItemIds: nextReviewItems.map((item) => item.id), - }); - } - return true; - } - - cancelActiveGeneration(): void { - this._abortController?.abort(); - this._abortController = null; - if (this._state.activeGeneration) { - this._setState({ - status: "idle", - activeGeneration: { - ...this._state.activeGeneration, - status: "cancelled", - structuredPreview: null, - }, - }); - if (this._state.activeGeneration.sessionId) { - if (this._state.activeGeneration.turnId) { - this._updateSessionTurn( - this._state.activeGeneration.sessionId, - this._state.activeGeneration.turnId, - { status: "cancelled" }, - ); - } - this._updateSession(this._state.activeGeneration.sessionId, { - status: "cancelled", - }); - } - } - this._inlineCompletion.dismissSuggestion(); - } - - openCommandMenu(): void { - this._setState({ commandMenuOpen: true }); - } - - closeCommandMenu(): void { - this._setState({ commandMenuOpen: false }); - } - - setSuggestMode(enabled: boolean): void { - this._setState({ suggestMode: enabled }); - } - - showEphemeralSuggestion( - suggestion: Parameters[0], - ): void { - this._inlineCompletion.showSuggestion(suggestion); - } - - dismissEphemeralSuggestion(): void { - this._inlineCompletion.dismissSuggestion(); - } - - acceptEphemeralSuggestion(): void { - this._inlineCompletion.acceptSuggestion(); - } - - getSuggestions() { - return this._suggestions; - } - - handleDocumentChange(events: readonly { origin: string; affectedBlocks: readonly string[] }[]): void { - if (events.length > 0) { - this._documentVersion += 1; - } - const previousState = this._state; - const suggestionsChanged = this._syncSuggestionsFromDocument(); - const sessionsChanged = this._syncSessionsFromDocument(); - this.handleExternalCommit(events); - if (this._state === previousState) { - this._editor.requestDecorationUpdate(); - if (suggestionsChanged || sessionsChanged) { - this._emit(); - } - } - } - - private _syncSuggestionResolutionState(): void { - const suggestionsChanged = this._syncSuggestionsFromDocument(); - const sessionsChanged = this._syncSessionsFromDocument(); - if (!suggestionsChanged && !sessionsChanged) { - return; - } - this._editor.requestDecorationUpdate(); - this._emit(); - } - - acceptSuggestion(id: string): boolean { - const accepted = acceptSuggestion(this._editor, id); - if (accepted) { - this._syncSuggestionResolutionState(); - } - return accepted; - } - - rejectSuggestion(id: string): boolean { - const rejected = rejectSuggestion(this._editor, id); - if (rejected) { - this._syncSuggestionResolutionState(); - } - return rejected; - } - - private _rejectPreviewSuggestions(suggestionIds: readonly string[]): void { - if (suggestionIds.length === 0) { - return; - } - const rejected = rejectSuggestions(this._editor, suggestionIds, { - origin: AI_SESSION_SUGGESTION_ORIGIN, - undoGroupId: this._state.activeGeneration?.undoGroupId, - }); - if (rejected) { - this._syncSuggestionResolutionState(); - } - } - - acceptAllSuggestions(): void { - acceptAllSuggestions(this._editor); - this._syncSuggestionResolutionState(); - } - - rejectAllSuggestions(): void { - rejectAllSuggestions(this._editor); - this._syncSuggestionResolutionState(); - } - - buildDecorations(): Decoration[] { - const decorations = [ - ...buildTrackChangesDecorations(this._editor), - ...buildAffectedRangeDecorations( - this._editor, - this._state.sessions, - this._state.activeSessionId, - ), - ...buildGenerationZoneDecorations(this._state.activeGeneration), - ]; - return decorations; - } - - handleExternalCommit(events: readonly { origin: string; affectedBlocks: readonly string[] }[]): void { - const active = this._state.activeGeneration; - if (!active || active.status !== "streaming") return; - if ( - active.route === "tool-loop" || - active.route === "context-first" || - active.route === "review" - ) { - return; - } - const touched = events.some((event) => - event.origin !== "ai" && - event.origin !== AI_SESSION_SUGGESTION_ORIGIN && - event.origin !== "system" && - event.origin !== "extension" && - event.affectedBlocks.includes(active.blockId), - ); - if (!touched) return; - this.cancelActiveGeneration(); - } - - private async _runBlockGeneration( - prompt: string, - blockId: string, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - const block = this._editor.getBlock(blockId); - if (!block) { - throw new Error(`Block "${blockId}" not found`); - } - - const target: GenerationTarget = { - type: "block", - blockId, - offset: resolveBlockInsertionOffset(this._editor, blockId), - }; - return this._executeGeneration(prompt, target, commandId, maxSteps, context); - } - - private async _runDocumentGeneration( - prompt: string, - preferredBlockId?: string | null, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - const documentTarget = - context?.operation?.target.kind === "document" - ? context.operation.target - : null; - const replaceBlockIds = - documentTarget?.blockIds && documentTarget.blockIds.length > 0 - ? [...documentTarget.blockIds] - : context?.replaceBlockIds; - const insertionAnchor = resolveDocumentInsertionAnchor(this._editor, { - preferredBlockId: - documentTarget?.activeBlockId ?? - documentTarget?.blockIds?.[0] ?? - preferredBlockId ?? - resolveActiveBlockId(this._editor.selection) ?? - null, - }); - if (!insertionAnchor) { - throw new Error("Cannot run an AI document prompt without an insertion anchor"); - } - - return this._runBlockGeneration( - prompt, - insertionAnchor.blockId, - commandId, - maxSteps, - { - ...context, - replaceTargetBlock: - documentTarget?.placement === "replace-blocks" || - documentTarget?.placement === "replace-empty-block" || - insertionAnchor.strategy === "replace-empty-block" || - (replaceBlockIds?.length ?? 0) > 0, - replaceBlockIds, - }, - ); - } - - private async _runSelectionGeneration( - prompt: string, - selection: TextSelection, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - return this._executeGeneration( - prompt, - { type: "selection", selection }, - commandId, - maxSteps, - context, - ); - } - - private async _executeLocalOperation(input: { - prompt: string; - target: GenerationTarget; - blockId: string; - commandId?: string; - context?: GenerationExecutionContext; - abortController: AbortController; - baselineSuggestionIds: Set; - operation: AIRequestedOperation; - }): Promise { - const { - prompt, - target, - blockId, - commandId, - context, - abortController, - baselineSuggestionIds, - operation, - } = input; - const sessionTurnId = context?.sessionId ? crypto.randomUUID() : undefined; - const mutationMode: NonNullable = - "persistent-suggestions"; - const contentFormat = resolveLocalOperationContentFormat( - this._editor, - operation, - this._resolveContentFormat("block", context?.surface), - ); - const streamsMarkdownSelectionPreview = - operation.kind === "rewrite-selection" && - operation.target.kind === "scoped-range" && - contentFormat === "markdown" && - operation.target.blockIds.length > 0; - const applyStrategy: AIApplyStrategy | undefined = - ( - operation.kind === "rewrite-block" || - streamsMarkdownSelectionPreview || - ( - operation.kind === "document-transform" && - operation.target.kind === "document" && - ( - operation.target.placement === "replace-blocks" || - operation.target.placement === "replace-empty-block" - ) - ) - ) && contentFormat === "markdown" - ? "markdown-full-replace" - : undefined; - const seedGeneration: GenerationState = { - id: crypto.randomUUID(), - zoneId: crypto.randomUUID(), - blockId, - target: target.type, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - prompt, - operation, - status: "streaming", - tokenCount: 0, - steps: [], - undoGroupId: crypto.randomUUID(), - text: "", - commandId, - suggestionIds: [], - route: - operation.kind === "rewrite-selection" - ? "selection-rewrite" - : operation.kind === "continue-block" - ? "cursor-context" - : "context-first", - mutationMode, - contentFormat, - applyStrategy, - planState: "none", - plan: null, - structuredIntent: null, - reviewItems: [], - structuredPreview: null, - targetKind: undefined, - blockClass: "flow", - adapterId: "flow-markdown", - transportKind: "flow-text", - mutationReceipt: null, - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - }, - }; - const existingSession = - context?.sessionId != null - ? (this._state.sessions.find( - (session) => session.id === context.sessionId, - ) ?? null) - : null; - const executionPrompt = buildSessionExecutionPrompt(existingSession, prompt); - - if (context?.sessionId) { - const nextSelectionSnapshot = - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined; - this._updateSession(context.sessionId, { - status: "streaming", - operation, - activeTurnId: sessionTurnId, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : resolveSessionAnchor(this._editor.selection), - generationIds: appendUniqueString( - existingSession?.generationIds ?? [], - seedGeneration.id, - ), - promptHistory: [ - ...(existingSession?.promptHistory ?? []), - { - id: crypto.randomUUID(), - prompt, - createdAt: Date.now(), - generationId: seedGeneration.id, - operation, - }, - ], - turns: sessionTurnId - ? [ - ...(existingSession?.turns ?? []), - { - id: sessionTurnId, - prompt, - createdAt: Date.now(), - undoGroupId: seedGeneration.undoGroupId, - generationId: seedGeneration.id, - target: target.type, - operation, - status: "streaming", - suggestionIds: [], - reviewItemIds: [], - generatedBlockIds: [], - structuredPreview: null, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : undefined, - selection: - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined, - }, - ] - : existingSession?.turns, - contextualPrompt: - existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: - target.type === "selection" - ? { - ...existingSession.contextualPrompt.anchor, - selectionSnapshot: nextSelectionSnapshot, - focusBlockId: target.selection.toRange().start.blockId, - status: "valid", - } - : existingSession.contextualPrompt.anchor, - composer: { - ...existingSession.contextualPrompt.composer, - draftPrompt: "", - isSubmitting: true, - isOpen: true, - openReason: "user", - }, - } - : undefined, - }); - } - - this._setState({ - status: "thinking", - activeGeneration: seedGeneration, - commandMenuOpen: false, - lastRoute: seedGeneration.route, - activeSessionId: context?.sessionId ?? this._state.activeSessionId, - }); - this._setStreamEvents([ - createAIStreamEvent(seedGeneration, { - type: "generation-start", - prompt, - target: target.type, - }), - createAIStreamEvent(seedGeneration, { - type: "status", - status: "thinking", - }), - ]); - - let currentText = ""; - let currentMutationReceipt: AIMutationReceipt | null = null; - let sawStructuredFinalFrame = false; - let streamedSelectionSuggestionIds: string[] = []; - let lastStreamedSelectionPreviewText = ""; - const updatePreview = ( - text: string, - phase: "preview" | "final", - ) => { - currentText = text; - const nextStatus = - phase === "preview" && text.length > 0 ? "writing" : this._state.status; - if (phase === "preview" && text.length > 0) { - this._setState({ status: "writing" }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "status", - status: "writing", - }), - ); - } - this._resolveActiveGeneration({ - text, - status: "streaming", - operation, - }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "operation", - operation, - phase, - text, - }), - ); - void nextStatus; - }; - - try { - const stream = this._model!.stream({ - messages: [{ role: "user", content: executionPrompt }], - tools: [], - signal: abortController.signal, - requestMode: resolveGenerationRequestMode({ - ...context, - targetType: target.type, - operation, - }), - operation, - }); - - for await (const event of stream) { - if (abortController.signal.aborted) { - break; - } - - if (event.type === "error") { - throw event.error; - } - - if (event.type === "conflict") { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "operation", - operation, - phase: "conflict", - reason: event.reason, - }), - ); - throw new Error(event.reason); - } - - if (event.type === "text-delta") { - if ( - operation.kind === "document-transform" || - streamsMarkdownSelectionPreview - ) { - currentText += event.delta; - if ( - streamsMarkdownSelectionPreview && - operation.target.kind === "scoped-range" - ) { - updatePreview(currentText, "preview"); - const previewRefresh = this._refreshStreamingMarkdownBlockPreview( - operation.target.blockIds?.[0] ?? operation.target.anchor.blockId, - currentText, - mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedSelectionSuggestionIds, - lastStreamedSelectionPreviewText, - true, - operation.target.blockIds, - ); - streamedSelectionSuggestionIds = previewRefresh.suggestionIds; - lastStreamedSelectionPreviewText = previewRefresh.normalizedText; - } - continue; - } - throw new Error( - "Local AI operations must stream typed operation payloads, not raw text deltas.", - ); - } - - if ( - event.type === "replace-preview" || - event.type === "insert-preview" - ) { - updatePreview(event.text, "preview"); - if ( - streamsMarkdownSelectionPreview && - operation.target.kind === "scoped-range" - ) { - const previewRefresh = this._refreshStreamingMarkdownBlockPreview( - operation.target.blockIds?.[0] ?? operation.target.anchor.blockId, - event.text, - mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedSelectionSuggestionIds, - lastStreamedSelectionPreviewText, - true, - operation.target.blockIds, - ); - streamedSelectionSuggestionIds = previewRefresh.suggestionIds; - lastStreamedSelectionPreviewText = previewRefresh.normalizedText; - } - continue; - } - - if ( - event.type === "replace-final" || - event.type === "insert-final" - ) { - sawStructuredFinalFrame = true; - updatePreview(event.text, "final"); - if ( - streamsMarkdownSelectionPreview && - operation.target.kind === "scoped-range" - ) { - this._rejectPreviewSuggestions(streamedSelectionSuggestionIds); - streamedSelectionSuggestionIds = []; - lastStreamedSelectionPreviewText = ""; - } - currentMutationReceipt = this._commitRequestedOperationResult( - operation, - event.text, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); - continue; - } - - if (event.type === "done") { - break; - } - } - - if ( - !sawStructuredFinalFrame && - currentText.length > 0 && - operation.kind !== "document-transform" && - !streamsMarkdownSelectionPreview - ) { - throw new Error( - "Local AI operations must return a validated final payload before they can be applied.", - ); - } - if ( - !sawStructuredFinalFrame && - currentText.length > 0 && - operation.kind === "document-transform" - ) { - currentMutationReceipt = this._commitRequestedOperationResult( - operation, - currentText, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); - } else if ( - !sawStructuredFinalFrame && - currentText.length > 0 && - streamsMarkdownSelectionPreview - ) { - this._rejectPreviewSuggestions(streamedSelectionSuggestionIds); - streamedSelectionSuggestionIds = []; - lastStreamedSelectionPreviewText = ""; - currentMutationReceipt = this._commitRequestedOperationResult( - operation, - currentText, - context?.sessionId, - { - contentFormat, - applyStrategy, - }, - ); - } - - const suggestionIds = this.getSuggestions() - .map((item) => item.id) - .filter((id) => !baselineSuggestionIds.has(id)); - const mutationReceipt = - currentMutationReceipt ?? - buildMutationReceipt({ - status: currentText.length > 0 ? "noop" : "noop", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - const finalStatus = abortController.signal.aborted ? "cancelled" : "complete"; - this._setState({ - status: "idle", - activeGeneration: { - ...seedGeneration, - text: currentText, - status: finalStatus, - suggestionIds, - mutationReceipt, - }, - }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "generation-finish", - status: finalStatus, - text: currentText, - }), - ); - if (context?.sessionId) { - if (sessionTurnId) { - const localReceiptEvidence = mutationReceipt?.evidence; - const localGeneratedBlockIds = localReceiptEvidence - ? [ - ...new Set([ - ...localReceiptEvidence.affectedBlockIds, - ...localReceiptEvidence.createdBlockIds, - ]), - ] - : operation.kind === "rewrite-selection" && - operation.target.kind === "scoped-range" - ? [...operation.target.blockIds] - : []; - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: finalStatus === "cancelled" ? "cancelled" : "complete", - suggestionIds, - generatedBlockIds: localGeneratedBlockIds, - }); - } - this._updateSession(context.sessionId, { - status: finalStatus === "cancelled" ? "cancelled" : "complete", - pendingSuggestionIds: suggestionIds, - pendingReviewItemIds: [], - }); - } - return { - ...seedGeneration, - text: currentText, - status: finalStatus, - suggestionIds, - mutationReceipt, - }; - } catch (error) { - this._setState({ - status: "idle", - activeGeneration: { - ...seedGeneration, - text: currentText, - status: abortController.signal.aborted ? "cancelled" : "error", - }, - }); - if (context?.sessionId) { - if (sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: abortController.signal.aborted ? "cancelled" : "error", - }); - } - this._updateSession(context.sessionId, { - status: abortController.signal.aborted ? "cancelled" : "error", - }); - } - throw error; - } finally { - if (this._abortController === abortController) { - this._abortController = null; - } - } - } - - private async _executeGeneration( - prompt: string, - target: GenerationTarget, - commandId?: string, - maxSteps?: number, - context?: GenerationExecutionContext, - ): Promise { - if (!this._model) { - throw new Error("No AI model configured"); - } - - this.cancelActiveGeneration(); - const toolRuntime = - getDocumentToolRuntime(this._editor) ?? - EMPTY_TOOL_RUNTIME; - const abortController = new AbortController(); - this._abortController = abortController; - - const baselineSuggestionIds = new Set(this.getSuggestions().map((item) => item.id)); - const blockId = - target.type === "block" - ? target.blockId - : target.selection.toRange().start.blockId; - const requestedOperation = context?.operation ?? null; - if ( - context?.surface === "bottom-chat" && - isLocalRequestedOperation(requestedOperation) - ) { - return this._executeLocalOperation({ - prompt, - target, - blockId, - commandId, - context, - abortController, - baselineSuggestionIds, - operation: requestedOperation, - }); - } - const requestedContentFormat = this._resolveContentFormat( - target.type, - context?.surface, - ); - let route = routeAIRequest({ - prompt, - selection: this._editor.selection, - blockType: this._editor.getBlock(blockId)?.type ?? null, - blockCount: this._editor.blockCount(), - suggestMode: this._state.suggestMode, - target: target.type, - contentFormat: requestedContentFormat, - surface: context?.surface, - }); - let workingSet = await this._buildWorkingSet( - toolRuntime, - route, - target, - blockId, - prompt, - ); - const refinedRoute = this._refineRouteWithWorkingSet(route, workingSet); - if (refinedRoute.lane !== route.lane) { - route = refinedRoute; - workingSet = await this._buildWorkingSet( - toolRuntime, - route, - target, - blockId, - prompt, - ); - } else { - route = refinedRoute; - } - const adapter = getBlockAdapter(route.adapterId); - const contentFormat = route.contentFormat; - let currentText = ""; - const streamingTarget = - this._editor.internals.getSlot("delta-stream:target") ?? - null; - let blockStreamingStarted = false; - const shouldStreamDirectly = route.shouldStreamDirectly; - const selectionRange = - target.type === "selection" ? target.selection.toRange() : null; - const selectionSourceText = - target.type === "selection" - ? resolveSelectionText(this._editor, target.selection) - : ""; - const shouldStreamSuggestedText = - route.mutationMode === "streaming-suggestions" && - route.plannerMode !== "structured" && - contentFormat === "text"; - const shouldReplaceMarkdownTarget = - (context?.replaceTargetBlock === true) || - ( - route.plannerMode !== "structured" && - contentFormat === "markdown" && - target.type === "block" && - ( - route.targetKind === "table" || - ( - context?.surface === "bottom-chat" && - shouldReplaceEmptyMarkdownTarget(this._editor.getBlock(blockId)) - ) - ) - ); - const canStreamSelectionSuggestions = - shouldStreamSuggestedText && - target.type === "selection" && - selectionRange?.start.blockId === selectionRange?.end.blockId; - const canStreamBlockSuggestions = - shouldStreamSuggestedText && target.type === "block"; - const canStreamMarkdownBlockSuggestions = - route.mutationMode === "streaming-suggestions" && - route.plannerMode !== "structured" && - contentFormat === "markdown" && - target.type === "block" && - route.applyStrategy === "markdown-full-replace" && - context?.surface === "bottom-chat"; - let streamedSuggestionInitialized = false; - let streamedSuggestionLength = 0; - let streamedMarkdownSuggestionIds: string[] = []; - let lastStreamedMarkdownPreviewText = ""; - const sessionTurnId = context?.sessionId ? crypto.randomUUID() : undefined; - const existingSession = - context?.sessionId != null - ? (this._state.sessions.find( - (session) => session.id === context.sessionId, - ) ?? null) - : null; - const executionPrompt = buildSessionExecutionPrompt(existingSession, prompt); - let shouldTrimLeadingBlankBlockText = - target.type === "block" && - shouldTrimLeadingBlankBlockGenerationText(this._editor.getBlock(blockId)); - const useStructuredIntentTransport = - adapter.transportKind !== "flow-text" && supportsStructuredIntent(this._model); - const generationPrompt = - useStructuredIntentTransport || - (adapter.id === "flow-markdown" && contentFormat === "markdown") - ? adapter.buildPrompt({ - prompt: executionPrompt, - targetKind: route.targetKind, - activeBlockId: blockId, - workingSet, - applyStrategy: route.applyStrategy, - }) - : route.plannerMode === "structured" - ? buildPlannerPrompt({ - prompt: executionPrompt, - targetKind: route.targetKind, - workingSet, - }) - : executionPrompt; - - const seedGeneration: GenerationState = { - id: crypto.randomUUID(), - zoneId: crypto.randomUUID(), - blockId, - target: target.type, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - prompt, - operation: requestedOperation, - status: "streaming", - tokenCount: 0, - steps: [], - undoGroupId: crypto.randomUUID(), - text: "", - commandId, - suggestionIds: [], - route: route.lane, - mutationMode: route.mutationMode, - contentFormat, - applyStrategy: route.applyStrategy, - planState: "none", - plan: null, - structuredIntent: null, - reviewItems: [], - structuredPreview: null, - targetKind: route.targetKind, - blockClass: route.blockClass, - adapterId: route.adapterId, - transportKind: route.transportKind, - mutationReceipt: null, - debug: { - messageAssemblyLatencyMs: 0, - firstToolStartMs: null, - firstToolResultMs: null, - firstVisibleTextMs: null, - toolExecutionMs: 0, - qualitySignals: {}, - routeConfidence: workingSet?.routeConfidence, - structured: { - plannerMode: route.plannerMode, - executionMode: resolveExecutionMode(route.mutationMode), - targetKind: route.targetKind, - validationIssueCount: 0, - }, - fastApply: { - attempted: false, - succeeded: false, - }, - }, - }; - if (context?.sessionId) { - const nextSelectionSnapshot = - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined; - this._updateSession(context.sessionId, { - status: "streaming", - operation: requestedOperation, - activeTurnId: sessionTurnId, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : resolveSessionAnchor(this._editor.selection), - generationIds: appendUniqueString( - existingSession?.generationIds ?? [], - seedGeneration.id, - ), - promptHistory: [ - ...(existingSession?.promptHistory ?? []), - { - id: crypto.randomUUID(), - prompt, - createdAt: Date.now(), - generationId: seedGeneration.id, - operation: requestedOperation ?? undefined, - }, - ], - turns: sessionTurnId - ? [ - ...(existingSession?.turns ?? []), - { - id: sessionTurnId, - prompt, - createdAt: Date.now(), - undoGroupId: seedGeneration.undoGroupId, - generationId: seedGeneration.id, - target: target.type, - operation: requestedOperation ?? undefined, - status: "streaming", - suggestionIds: [], - reviewItemIds: [], - generatedBlockIds: [], - structuredPreview: null, - anchor: - target.type === "selection" - ? resolveSessionAnchor(target.selection) - : undefined, - selection: - target.type === "selection" - ? resolveSessionSelectionSnapshot(target.selection) - : undefined, - }, - ] - : existingSession?.turns, - contextualPrompt: - existingSession?.contextualPrompt - ? { - ...existingSession.contextualPrompt, - anchor: - target.type === "selection" - ? { - ...existingSession.contextualPrompt.anchor, - selectionSnapshot: nextSelectionSnapshot, - focusBlockId: target.selection.toRange().start.blockId, - status: "valid", - } - : existingSession.contextualPrompt.anchor, - composer: { - ...existingSession.contextualPrompt.composer, - draftPrompt: "", - isSubmitting: true, - isOpen: true, - openReason: "user", - }, - } - : undefined, - }); - } - this._setState({ - status: "thinking", - activeGeneration: seedGeneration, - commandMenuOpen: false, - lastRoute: route.lane, - activeSessionId: context?.sessionId ?? this._state.activeSessionId, - }); - let currentStructuredPreview: GenerationStructuredPreviewState | null = null; - let currentStructuredIntent: GenerationState["structuredIntent"] = null; - let currentMutationReceipt: AIMutationReceipt | null = null; - this._setStreamEvents([ - createAIStreamEvent(seedGeneration, { - type: "generation-start", - prompt, - target: target.type, - }), - createAIStreamEvent(seedGeneration, { - type: "status", - status: "thinking", - }), - ]); - - try { - const result = await runAgenticLoop({ - model: this._model, - editor: this._editor, - toolRuntime: route.allowToolUse ? toolRuntime : EMPTY_TOOL_RUNTIME, - prompt: generationPrompt, - blockId, - generationId: seedGeneration.id, - zoneId: seedGeneration.zoneId, - maxSteps: route.allowToolUse ? (maxSteps ?? this._maxAgenticSteps) : 1, - signal: abortController.signal, - requestMode: resolveGenerationRequestMode({ - ...context, - targetType: target.type, - }), - workingSet, - validateWorkingSet: (activeWorkingSet) => - this._validateWorkingSet(route, target, activeWorkingSet), - refreshWorkingSet: async () => - this._buildWorkingSet(toolRuntime, route, target, blockId, prompt), - onStatusChange: (status) => { - this._setState({ status }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "status", - status, - }), - ); - }, - onStep: (step) => { - const active = this._state.activeGeneration; - if (!active) return; - this._setState({ - activeGeneration: { - ...active, - steps: [...active.steps, step], - }, - }); - }, - onTextDelta: (delta) => { - const nextDelta = - target.type === "block" && shouldTrimLeadingBlankBlockText - ? trimLeadingBlankBlockGenerationText(delta) - : delta; - if (shouldTrimLeadingBlankBlockText && nextDelta.length > 0) { - shouldTrimLeadingBlankBlockText = false; - } - if (nextDelta.length === 0) { - return; - } - currentText += nextDelta; - if (target.type === "block" && shouldStreamDirectly) { - streamingTarget?.appendDelta(nextDelta); - } else if (canStreamSelectionSuggestions && selectionRange) { - if (!streamedSuggestionInitialized) { - this._applySuggestedAIOps( - [{ - type: "replace-text", - blockId: selectionRange.start.blockId, - offset: selectionRange.start.offset, - length: selectionRange.end.offset - selectionRange.start.offset, - text: nextDelta, - }], - context?.sessionId, - { undoGroupId: seedGeneration.undoGroupId }, - ); - streamedSuggestionInitialized = true; - streamedSuggestionLength = nextDelta.length; - } else if (nextDelta.length > 0) { - this._applySuggestedAIOps( - [{ - type: "insert-text", - blockId: selectionRange.start.blockId, - offset: selectionRange.end.offset + streamedSuggestionLength, - text: nextDelta, - }], - context?.sessionId, - { undoGroupId: seedGeneration.undoGroupId }, - ); - streamedSuggestionLength += nextDelta.length; - } - } else if (canStreamBlockSuggestions && target.type === "block") { - if (nextDelta.length > 0) { - this._applySuggestedAIOps( - [{ - type: "insert-text", - blockId: target.blockId, - offset: target.offset + streamedSuggestionLength, - text: nextDelta, - }], - context?.sessionId, - { undoGroupId: seedGeneration.undoGroupId }, - ); - streamedSuggestionLength += nextDelta.length; - } - } else if (canStreamMarkdownBlockSuggestions && target.type === "block") { - const previewRefresh = this._refreshStreamingMarkdownBlockPreview( - target.blockId, - currentText, - route.mutationMode, - context?.sessionId, - baselineSuggestionIds, - streamedMarkdownSuggestionIds, - lastStreamedMarkdownPreviewText, - shouldReplaceMarkdownTarget, - context?.replaceBlockIds, - ); - streamedMarkdownSuggestionIds = previewRefresh.suggestionIds; - lastStreamedMarkdownPreviewText = previewRefresh.normalizedText; - } else if (target.type === "selection") { - this._inlineCompletion.showSuggestion({ - id: seedGeneration.id, - blockId: blockId, - offset: target.selection.toRange().start.offset, - text: currentText, - type: "inline", - }); - } - const active = this._state.activeGeneration; - if (!active) return; - this._setState({ - activeGeneration: { - ...active, - text: currentText, - status: "streaming", - }, - }); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "text-delta", - delta: nextDelta, - text: currentText, - }), - ); - if (route.plannerMode === "structured" && !useStructuredIntentTransport) { - const previewResult = parseStructuredPlanPreview( - currentText, - route.targetKind, - ); - if (previewResult?.plan) { - const nextStructuredPreview = - buildGenerationStructuredPreviewState(this._editor, { - planState: previewResult.planState === "validated" - ? "validated" - : "drafted", - plan: previewResult.plan, - }); - if ( - !areStructuredValuesEqual( - currentStructuredPreview, - nextStructuredPreview, - ) - ) { - const patches = buildStructuredPreviewPatchOperations( - currentStructuredPreview, - nextStructuredPreview, - ); - currentStructuredPreview = nextStructuredPreview; - this._resolveActiveGeneration({ - structuredPreview: nextStructuredPreview, - }); - if (context?.sessionId && sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - reviewItemIds: nextStructuredPreview.reviewItems.map((item) => item.id), - structuredPreview: nextStructuredPreview, - }); - } - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "structured-preview", - preview: nextStructuredPreview, - patches, - }), - ); - } - } - } - }, - onStructuredData: (event) => { - if (!useStructuredIntentTransport) { - return; - } - const previewResult = adapter.parsePreview?.({ - value: event.data, - targetKind: route.targetKind, - activeBlockId: blockId, - }) ?? null; - if (!previewResult?.intent) { - return; - } - currentStructuredIntent = previewResult.intent; - const compilation = compileStructuredIntentToPlan(previewResult.intent, { - activeBlockId: blockId, - }); - if (!compilation.plan) { - return; - } - const nextStructuredPreview = buildGenerationStructuredPreviewState( - this._editor, - { - planState: - previewResult.intentState === "validated" && - compilation.issues.length === 0 - ? "validated" - : "drafted", - plan: compilation.plan, - }, - ); - if ( - areStructuredValuesEqual( - currentStructuredPreview, - nextStructuredPreview, - ) - ) { - return; - } - const patches = buildStructuredPreviewPatchOperations( - currentStructuredPreview, - nextStructuredPreview, - ); - currentStructuredPreview = nextStructuredPreview; - this._resolveActiveGeneration({ - structuredIntent: previewResult.intent, - structuredPreview: nextStructuredPreview, - }); - if (context?.sessionId && sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - reviewItemIds: nextStructuredPreview.reviewItems.map((item) => item.id), - structuredPreview: nextStructuredPreview, - }); - } - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "app-partial", - data: event.data, - final: event.final, - }), - ); - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "structured-preview", - preview: nextStructuredPreview, - patches, - }), - ); - }, - onToolCall: (event) => { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: event.input, - }), - ); - }, - onToolOutput: (event) => { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "tool-output", - toolCallId: event.toolCallId, - toolName: event.toolName, - part: event.part, - output: event.output, - }), - ); - }, - onToolResult: (event) => { - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "tool-result", - toolCallId: event.toolCallId, - toolName: event.toolName, - output: event.output, - state: event.state, - }), - ); - }, - onDebug: (debug) => { - const active = this._state.activeGeneration; - if (!active) return; - this._setState({ - activeGeneration: { - ...active, - debug, - }, - }); - }, - onStreamingStart: (zoneId, targetBlockId) => { - if (target.type !== "block" || !shouldStreamDirectly || blockStreamingStarted) return; - streamingTarget?.beginStreaming(zoneId, targetBlockId); - blockStreamingStarted = true; - }, - onStreamingEnd: (status) => { - if (target.type !== "block" || !shouldStreamDirectly || !blockStreamingStarted) return; - streamingTarget?.endStreaming(status); - blockStreamingStarted = false; - }, - }); - - if ( - target.type === "selection" && - currentText.length > 0 && - !canStreamSelectionSuggestions - ) { - currentMutationReceipt = this._commitSelectionRewrite( - target.selection, - currentText, - route.mutationMode, - context?.sessionId, - ); - this._inlineCompletion.dismissSuggestion(); - } else if ( - target.type === "selection" && - currentText.length > 0 && - canStreamSelectionSuggestions - ) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - contextChars: selectionSourceText.length, - diffChars: currentText.length, - }); - } else if ( - target.type === "block" && - currentText.length > 0 && - !shouldStreamDirectly && - !canStreamBlockSuggestions && - !canStreamMarkdownBlockSuggestions && - route.plannerMode !== "structured" - ) { - currentMutationReceipt = this._commitBufferedBlockGeneration( - target.blockId, - currentText, - route.mutationMode, - contentFormat, - context?.sessionId, - { - applyStrategy: route.applyStrategy, - insertionOffset: target.offset, - workingSet, - replaceTargetBlock: shouldReplaceMarkdownTarget, - replaceBlockIds: context?.replaceBlockIds, - }, - ); - this._inlineCompletion.dismissSuggestion(); - } - - const suggestionIds = this.getSuggestions() - .map((item) => item.id) - .filter((id) => !baselineSuggestionIds.has(id)); - const structuredPlanResult = - route.plannerMode === "structured" && !useStructuredIntentTransport - ? parseStructuredPlanResult(currentText, route.targetKind) - : null; - const structuredIntentResolution = - useStructuredIntentTransport - ? adapter.resolveResult?.({ - value: currentStructuredIntent, - targetKind: route.targetKind, - activeBlockId: blockId, - }) ?? null - : null; - const structuredIntentResult = structuredIntentResolution?.parseResult ?? null; - const structuredIntentCompilation = - structuredIntentResolution?.compilation ?? null; - const resolvedStructuredPlan = - structuredIntentCompilation?.plan ?? structuredPlanResult?.plan ?? null; - const planExecution = resolvedStructuredPlan - ? buildDocumentMutationPlanExecution( - this._editor, - resolvedStructuredPlan, - ) - : null; - const reviewItems = - resolvedStructuredPlan && - route.mutationMode !== "direct-stream" && - (!planExecution || !planExecution.reviewSafe) - ? buildStructuralReviewItems(this._editor, resolvedStructuredPlan) - : []; - - if ( - resolvedStructuredPlan && - planExecution && - planExecution.issues.length === 0 - ) { - currentMutationReceipt = this._commitStructuredPlan( - planExecution.ops, - planExecution.reviewSafe, - route.mutationMode, - route.adapterId, - route.blockClass, - route.transportKind, - ); - } - if (!currentMutationReceipt) { - currentMutationReceipt = this._buildFallbackMutationReceipt({ - currentText, - suggestionIds, - reviewItems, - planExecutionIssueCount: planExecution?.issues.length ?? 0, - adapterId: route.adapterId, - blockClass: route.blockClass, - transportKind: route.transportKind, - }); - } - const structuredDebug = { - plannerMode: route.plannerMode, - executionMode: resolveExecutionMode(route.mutationMode), - targetKind: route.targetKind, - validationIssueCount: - (structuredPlanResult?.issues.length ?? 0) + - (structuredIntentResult?.issues.length ?? 0) + - (structuredIntentCompilation?.issues.length ?? 0) + - (planExecution?.issues.length ?? 0), - }; - const resolvedDebug = - this._state.activeGeneration?.id === seedGeneration.id - ? (this._state.activeGeneration.debug ?? result.debug ?? seedGeneration.debug!) - : (result.debug ?? seedGeneration.debug!); - const resolvedPlanState: GenerationState["planState"] = - planExecution && planExecution.issues.length > 0 - ? "rejected" - : structuredIntentResult?.intentState === "validated" && - (structuredIntentCompilation?.issues.length ?? 0) === 0 - ? "validated" - : structuredIntentResult?.intentState === "drafted" - ? "drafted" - : structuredPlanResult?.planState ?? seedGeneration.planState; - - const finalGeneration: GenerationState = { - ...result, - blockId, - target: target.type, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - commandId, - text: currentText, - suggestionIds, - route: route.lane, - mutationMode: route.mutationMode, - contentFormat, - planState: resolvedPlanState, - plan: resolvedStructuredPlan, - structuredIntent: - structuredIntentResult?.intent ?? currentStructuredIntent ?? null, - reviewItems, - structuredPreview: - resolvedStructuredPlan - ? buildGenerationStructuredPreviewState(this._editor, { - planState: - planExecution && planExecution.issues.length === 0 - ? "validated" - : "drafted", - plan: resolvedStructuredPlan, - }) - : currentStructuredPreview, - targetKind: route.targetKind, - blockClass: route.blockClass, - adapterId: route.adapterId, - transportKind: route.transportKind, - mutationReceipt: currentMutationReceipt, - debug: { - ...resolvedDebug, - structured: structuredDebug, - }, - }; - this._abortController = null; - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "generation-finish", - status: finalGeneration.status, - text: currentText, - }), - ); - this._setState({ - status: "idle", - activeGeneration: finalGeneration, - }); - if (context?.sessionId) { - const structuredPreviewEvents = this.getStreamEvents().filter( - (event) => - event.type === "structured-preview" && - event.sessionId === context.sessionId, - ); - const lastStructuredPreviewEvent = - structuredPreviewEvents[structuredPreviewEvents.length - 1]; - const refreshedInlineReviewSelectionTarget = - context?.surface === "inline-edit" && suggestionIds.length > 0 - ? resolvePendingInlineSelectionTarget( - this._editor, - requestedOperation ?? undefined, - suggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor) - : null; - if (sessionTurnId) { - const receiptEvidence = currentMutationReceipt?.evidence; - const generatedBlockIds = receiptEvidence - ? [ - ...new Set([ - ...receiptEvidence.affectedBlockIds, - ...receiptEvidence.createdBlockIds, - ]), - ] - : []; - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: - suggestionIds.length > 0 || reviewItems.length > 0 - ? "review" - : finalGeneration.status === "complete" - ? "complete" - : finalGeneration.status, - suggestionIds, - reviewItemIds: reviewItems.map((item) => item.id), - generatedBlockIds, - structuredPreview: finalGeneration.structuredPreview ?? null, - anchor: refreshedInlineReviewSelectionTarget - ? resolveSessionAnchor( - refreshedInlineReviewSelectionTarget.selection, - ) - : undefined, - selection: refreshedInlineReviewSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineReviewSelectionTarget.selection, - ) - : undefined, - }); - } - const resolvedGenerationDebug = - this._state.activeGeneration?.id === finalGeneration.id - ? this._state.activeGeneration.debug - : finalGeneration.debug; - this._recordSessionFastApplyMetrics( - context.sessionId, - resolvedGenerationDebug?.fastApply, - ); - this._updateSession(context.sessionId, { - status: finalGeneration.status === "complete" ? "complete" : finalGeneration.status, - pendingSuggestionIds: suggestionIds, - pendingReviewItemIds: reviewItems.map((item) => item.id), - metrics: { - ...(this._state.sessions.find((session) => session.id === context.sessionId) - ?.metrics ?? { - streamEventCount: 0, - patchCount: 0, - fastApply: createDefaultSessionFastApplyMetrics(), - }), - firstTokenMs: resolvedGenerationDebug?.firstVisibleTextMs ?? undefined, - totalMs: resolvedGenerationDebug?.messageAssemblyLatencyMs != null - ? resolvedGenerationDebug.messageAssemblyLatencyMs + - (resolvedGenerationDebug.toolExecutionMs ?? 0) - : undefined, - toolMs: resolvedGenerationDebug?.toolExecutionMs ?? undefined, - streamEventCount: this._streamEvents.filter( - (event) => event.sessionId === context.sessionId, - ).length, - patchCount: - lastStructuredPreviewEvent?.type === "structured-preview" - ? lastStructuredPreviewEvent.patches.length - : 0, - }, - }); - } - - if (finalGeneration.status === "complete") { - this._editor.internals.emit("diagnostic", { - level: "info", - source: "ai", - code: "GENERATION_COMPLETE", - message: "AI generation completed", - blockId, - generationId: finalGeneration.id, - }); - } - - return finalGeneration; - } catch (error) { - const isStaleWorkingSet = - error instanceof Error && error.name === "StaleWorkingSetError"; - const failedGeneration: GenerationState = { - ...(this._state.activeGeneration ?? seedGeneration), - blockId, - sessionId: context?.sessionId, - turnId: sessionTurnId, - surface: context?.surface, - prompt, - commandId, - text: currentText, - status: - abortController.signal.aborted || isStaleWorkingSet - ? "cancelled" - : "error", - targetKind: route.targetKind, - }; - this._abortController = null; - this._inlineCompletion.dismissSuggestion(); - if (target.type === "block" && blockStreamingStarted) { - streamingTarget?.endStreaming( - abortController.signal.aborted ? "cancelled" : "error", - ); - blockStreamingStarted = false; - } - this._appendStreamEvent( - createAIStreamEvent(seedGeneration, { - type: "generation-finish", - status: failedGeneration.status, - text: currentText, - }), - ); - this._setState({ - status: "idle", - activeGeneration: failedGeneration, - }); - if (context?.sessionId) { - if (sessionTurnId) { - this._updateSessionTurn(context.sessionId, sessionTurnId, { - status: failedGeneration.status, - reviewItemIds: [], - structuredPreview: null, - }); - } - this._updateSession(context.sessionId, { - status: failedGeneration.status, - }); - } - if (abortController.signal.aborted || isStaleWorkingSet) { - return failedGeneration; - } - throw error; - } - } - - private _commitRequestedOperationResult( - operation: AIRequestedOperation, - text: string, - sessionId: string | undefined, - options: { - contentFormat: AIContentFormat; - applyStrategy?: AIApplyStrategy; - }, - ): AIMutationReceipt { - const conflictReason = resolveRequestedOperationConflict( - this._editor, - operation, - this._createSelectionSignature(this._editor.selection), - ); - if (conflictReason) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: [conflictReason], - }); - } - - if (operation.kind === "rewrite-selection") { - const selection = resolveSelectionForRequestedOperation( - this._editor, - operation, - ); - if (!selection) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested selection rewrite target is no longer available."], - }); - } - const markdownBlockIds = - options.contentFormat === "markdown" && - operation.target.kind === "scoped-range" && - operation.target.blockIds.length > 0 - ? operation.target.blockIds - : null; - if (markdownBlockIds) { - return this._commitBufferedBlockGeneration( - markdownBlockIds[0], - text, - "persistent-suggestions", - "markdown", - sessionId, - { - applyStrategy: options.applyStrategy, - replaceTargetBlock: true, - replaceBlockIds: markdownBlockIds, - }, - ); - } - return this._commitSelectionRewrite( - selection, - text, - "persistent-suggestions", - sessionId, - ); - } - - if (operation.kind === "rewrite-block") { - const target = operation.target.kind === "block" ? operation.target : null; - if (!target) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested block rewrite target is invalid."], - }); - } - const selection = resolveFullBlockTextSelection( - this._editor, - target.blockId, - ); - if (selection && options.contentFormat === "text") { - return this._commitSelectionRewrite( - selection, - text, - "persistent-suggestions", - sessionId, - ); - } - return this._commitBufferedBlockGeneration( - target.blockId, - text, - "persistent-suggestions", - options.contentFormat, - sessionId, - { - applyStrategy: options.applyStrategy, - replaceTargetBlock: true, - }, - ); - } - - if (operation.kind === "document-transform") { - const target = operation.target.kind === "document" ? operation.target : null; - if (!target) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested document transform target is invalid."], - }); - } - const replaceBlockIds = target.blockIds?.filter( - (blockId) => this._editor.getBlock(blockId) != null, - ); - if (target.transform === "remove") { - const deleteBlockIds = - replaceBlockIds && replaceBlockIds.length > 0 - ? replaceBlockIds - : this._editor.documentState.blockOrder.filter( - (blockId) => this._editor.getBlock(blockId) != null, - ); - const ops = deleteBlockIds.map((blockId) => ({ - type: "delete-block" as const, - blockId, - })); - if (ops.length === 0) { - return buildMutationReceipt({ - status: "noop", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._applySuggestedAIOps(ops, sessionId); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - const targetBlockId = - target.activeBlockId ?? - replaceBlockIds?.[0] ?? - this._editor.lastBlock()?.id ?? - this._editor.firstBlock()?.id ?? - null; - if (!targetBlockId) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested document transform target is no longer available."], - }); - } - return this._commitBufferedBlockGeneration( - targetBlockId, - text, - "persistent-suggestions", - options.contentFormat, - sessionId, - { - applyStrategy: options.applyStrategy, - replaceTargetBlock: - target.placement === "replace-blocks" || - target.placement === "replace-empty-block" || - (replaceBlockIds?.length ?? 0) > 0, - replaceBlockIds, - }, - ); - } - - const target = operation.target.kind === "block" ? operation.target : null; - if (!target) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["The requested continuation target is invalid."], - }); - } - return this._commitBufferedBlockGeneration( - target.blockId, - text, - "persistent-suggestions", - "text", - sessionId, - { - insertionOffset: target.insertionOffset, - }, - ); - } - - private _commitSelectionRewrite( - selection: TextSelection, - text: string, - mutationMode: NonNullable, - sessionId?: string, - ): AIMutationReceipt { - const selectedText = resolveSelectionText(this._editor, selection); - const ops = buildSelectionReplacementOps(this._editor, selection, text); - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - contextChars: selectedText.length, - diffChars: text.length, - }); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._editor.selectTextRange(selection.anchor, selection.focus); - this._editor.deleteSelection({ origin: "ai" }); - const nextSelection = this._editor.selection; - if (nextSelection?.type !== "text") { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: selectedText.length, - diffChars: text.length, - fallbackReason: "selection-lost", - }); - return buildMutationReceipt({ - status: "invalid", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["Selection rewrite lost the active text selection."], - }); - } - const caret = nextSelection.anchor; - if (text.length > 0) { - this._editor.apply( - [{ - type: "insert-text", - blockId: caret.blockId, - offset: caret.offset, - text, - }], - { origin: "ai" }, - ); - } - this._editor.selectTextRange( - { - blockId: caret.blockId, - offset: caret.offset + text.length, - }, - { - blockId: caret.blockId, - offset: caret.offset + text.length, - }, - ); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "native-fast-apply", - contextChars: selectedText.length, - diffChars: text.length, - }); - return buildMutationReceipt({ - status: "applied", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - private _commitBufferedBlockGeneration( - blockId: string, - text: string, - mutationMode: NonNullable, - contentFormat: AIContentFormat, - sessionId?: string, - options?: { - applyStrategy?: AIApplyStrategy; - insertionOffset?: number; - workingSet?: AIWorkingSetEnvelope | null; - replaceTargetBlock?: boolean; - replaceBlockIds?: readonly string[]; - }, - ): AIMutationReceipt { - let fastApplyFallbackMode: - | "plain-markdown" - | null = null; - if ( - contentFormat === "markdown" && - options?.applyStrategy === "markdown-fast-apply" && - (options?.replaceBlockIds?.length ?? 0) === 0 - ) { - const fastApplyReceipt = this._commitBufferedMarkdownFastApply( - blockId, - text, - mutationMode, - sessionId, - options.workingSet ?? null, - ); - if (fastApplyReceipt) { - return fastApplyReceipt; - } - if (!text.trim().startsWith(`<${MARKDOWN_FAST_APPLY_ROOT_TAG}>`)) { - // Backward compatibility: tolerate plain markdown when the model - // does not honor the fast-apply contract. - fastApplyFallbackMode = "plain-markdown"; - } else { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["Fast apply contract could not be compiled safely."], - }); - } - } - - const normalizedText = - contentFormat === "markdown" - ? normalizeFlowMarkdownOutput(text) - : text; - const scopedReplaceBlockIds = - contentFormat === "markdown" - ? options?.replaceBlockIds?.filter( - (candidateBlockId, index, allBlockIds) => - allBlockIds.indexOf(candidateBlockId) === index && - this._editor.getBlock(candidateBlockId) != null, - ) ?? [] - : []; - if (contentFormat === "markdown" && scopedReplaceBlockIds.length > 0) { - if (normalizedText.trim().length > 0) { - const verification = this._verifyMarkdownFastApplyResult( - scopedReplaceBlockIds, - normalizedText, - ); - if (!verification.valid) { - return buildMutationReceipt({ - status: "invalid", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - issues: ["Scoped markdown replacement could not be verified safely."], - }); - } - } - const ops = this._buildMarkdownScopedReplacementOps( - scopedReplaceBlockIds, - normalizedText, - ); - const scopedReplacementFallback = this._summarizeFastApplyFallbackOps( - "scoped-replacement", - ops, - scopedReplaceBlockIds.length, - ); - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - this._recordFastApplyDebug({ - executionPath: "scoped-replacement", - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: ops.length > 0 ? "staged_suggestions" : "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - this._recordFastApplyDebug({ - executionPath: "scoped-replacement", - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: ops.length > 0 ? "applied" : "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - if ( - contentFormat === "markdown" && - (mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review") && - this._applySuggestedMarkdownPlaceholderReplacement( - blockId, - normalizedText, - sessionId, - options?.replaceTargetBlock, - options?.replaceBlockIds, - ) - ) { - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - [], - ), - }); - } - return buildMutationReceipt({ - status: "staged_suggestions", - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - const ops = - contentFormat === "markdown" - ? this._buildMarkdownBlockGenerationOps( - blockId, - normalizedText, - options?.replaceTargetBlock, - options?.replaceBlockIds, - ) - : this._buildTextBlockGenerationOps( - blockId, - normalizedText, - options?.insertionOffset, - ); - if (ops.length === 0) { - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - ops, - ), - }); - } - return buildMutationReceipt({ - status: "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - ops, - ), - }); - } - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - if (fastApplyFallbackMode) { - this._recordFastApplyDebug({ - executionPath: "plain-markdown", - fallback: this._summarizeFastApplyFallbackOps( - "plain-markdown", - ops, - ), - }); - } - return buildMutationReceipt({ - status: "applied", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - private _commitBufferedMarkdownFastApply( - blockId: string, - text: string, - mutationMode: NonNullable, - sessionId: string | undefined, - workingSet: AIWorkingSetEnvelope | null, - ): AIMutationReceipt | null { - const fastApplyScope = this._resolveMarkdownFastApplyScope(blockId, workingSet); - if (!fastApplyScope) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - fallbackReason: "missing-scope", - }); - return null; - } - - const patchPlan = parseMarkdownPatchPlanContract(text); - if (patchPlan) { - const validation = validateDocumentMutationPlanShape( - patchPlan, - this._buildPlanValidationContext(blockId, fastApplyScope.blockIds), - ); - if (!validation.valid) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - fallbackReason: "invalid-patch-plan", - verificationFailureReason: validation.issues[0]?.message, - }); - return null; - } - - const execution = buildDocumentMutationPlanExecution(this._editor, patchPlan); - if (execution.issues.length > 0) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - fallbackReason: "patch-plan-execution", - verificationFailureReason: execution.issues[0]?.message, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return null; - } - - const verification = this._verifyFlowPatchPlanResult( - patchPlan, - execution.ops, - fastApplyScope.blockIds, - ); - if (!verification.valid) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - fallbackReason: "verification-failed", - verificationFailureReason: verification.reason, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return null; - } - - if (execution.ops.length === 0) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return buildMutationReceipt({ - status: "noop", - ops: execution.ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(execution.ops, sessionId); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return buildMutationReceipt({ - status: "staged_suggestions", - ops: execution.ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - contextChars: fastApplyScope.markdown.length, - diffChars: text.length, - confidence: patchPlan.confidence?.score, - untouchedBlockMutationCount: verification.untouchedBlockMutationCount, - alignment: execution.metrics?.flowPatchAlignment, - executionPath: "native-fast-apply", - }); - return buildMutationReceipt({ - status: "applied", - ops: execution.ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - const contract = parseMarkdownFastApplyContract(text); - if (!contract) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - fallbackReason: "unparseable-contract", - }); - return null; - } - - const merged = applyMarkdownFastApply({ - originalMarkdown: fastApplyScope.markdown, - contract, - }); - if (!merged.success || !merged.mergedMarkdown) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - confidence: merged.confidence, - fallbackReason: merged.fallbackReason ?? "merge-failed", - verificationFailureReason: merged.issues[0], - }); - return null; - } - - const verification = this._verifyMarkdownFastApplyResult( - fastApplyScope.blockIds, - merged.mergedMarkdown, - ); - if (!verification.valid) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: false, - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - fallbackReason: "verification-failed", - verificationFailureReason: verification.reason, - untouchedBlockMutationCount: 0, - }); - return null; - } - - const ops = this._buildMarkdownScopedReplacementOps( - fastApplyScope.blockIds, - merged.mergedMarkdown, - ); - const scopedReplacementFallback = this._summarizeFastApplyFallbackOps( - "scoped-replacement", - ops, - fastApplyScope.blockIds.length, - ); - if (ops.length === 0) { - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - untouchedBlockMutationCount: 0, - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: "noop", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - if ( - mutationMode === "persistent-suggestions" || - mutationMode === "streaming-suggestions" || - mutationMode === "staged-review" - ) { - this._applySuggestedAIOps(ops, sessionId); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - untouchedBlockMutationCount: 0, - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - this._recordFastApplyDebug({ - attempted: true, - succeeded: true, - executionPath: "scoped-replacement", - contextChars: fastApplyScope.markdown.length, - diffChars: merged.diff?.length ?? 0, - confidence: merged.confidence, - untouchedBlockMutationCount: 0, - fallback: scopedReplacementFallback, - }); - return buildMutationReceipt({ - status: "applied", - ops, - adapterId: "flow-markdown", - blockClass: "flow", - transportKind: "flow-text", - }); - } - - private _resolveMarkdownFastApplyScope( - blockId: string, - workingSet: AIWorkingSetEnvelope | null, - ): { markdown: string; blockIds: string[] } | null { - const context = - workingSet?.context && typeof workingSet.context === "object" - ? (workingSet.context as { - markdown?: string | null; - retrievedSpan?: AIWorkingSetRetrievedSpan | null; - markdownWindow?: { - blockIds?: string[]; - } | null; - }) - : null; - const markdown = context?.markdown?.trim() ?? ""; - const blockIds = context?.retrievedSpan?.blockIds?.length - ? context.retrievedSpan.blockIds - : context?.markdownWindow?.blockIds?.length - ? context.markdownWindow.blockIds - : [blockId]; - if (markdown.length === 0 || blockIds.length === 0) { - return null; - } - return { - markdown, - blockIds: [...new Set(blockIds)], - }; - } - - private _buildPlanValidationContext( - blockId: string, - scopeBlockIds: readonly string[], - ): Parameters[1] { - const knownBlockTypes = this._editor.schema - .allBlocks() - .filter((schema) => - shouldExposeBlockInTooling(this._editor.documentProfile, schema), - ) - .map((schema) => schema.type); - const editableTargetBlockIds = scopeBlockIds.filter((targetBlockId) => { - const block = this._editor.getBlock(targetBlockId); - if (!block) { - return false; - } - const schema = this._editor.schema.resolve(block.type); - return shouldExposeBlockInTooling(this._editor.documentProfile, schema); - }); - - return { - documentProfile: this._editor.documentProfile, - targetKind: this._resolvePlanValidationTargetKind(blockId), - knownBlockTypes, - allowedTargetBlockIds: [...scopeBlockIds], - editableTargetBlockIds, - }; - } - - private _resolvePlanValidationTargetKind(blockId: string): AITargetKind { - const blockType = this._editor.getBlock(blockId)?.type ?? null; - if (blockType === "database") { - return "database"; - } - if (blockType === "table") { - return "table"; - } - return "block"; - } - - private _verifyMarkdownFastApplyResult( - blockIds: readonly string[], - markdown: string, - ): { valid: boolean; reason?: string } { - if (markdown.trim().length === 0) { - return { valid: false, reason: "empty-merged-markdown" }; - } - const startBlockId = blockIds[0]; - const verificationResult = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: markdown, - position: startBlockId ? { before: startBlockId } : undefined, - surface: "ai-markdown-fast-apply-verify", - }); - if (verificationResult.blocks.length === 0) { - return { valid: false, reason: "markdown-parse-produced-no-blocks" }; - } - return { valid: true }; - } - - private _verifyFlowPatchPlanResult( - plan: { edits: Array<{ locator: { blockId?: string; blockIds?: string[] } }> }, - ops: readonly DocumentOp[], - scopeBlockIds: readonly string[], - ): { - valid: boolean; - reason?: string; - untouchedBlockMutationCount: number; - } { - const targetedBlockIds = new Set( - plan.edits.flatMap((edit) => [ - ...(edit.locator.blockId ? [edit.locator.blockId] : []), - ...(edit.locator.blockIds ?? []), - ]), - ); - const scopeSet = new Set(scopeBlockIds); - const mutatedExistingBlockIds = new Set(); - const outOfScopeMutations = new Set(); - const createdBlockIds = new Set(); - - for (const op of ops) { - if (op.type === "insert-block") { - createdBlockIds.add(op.blockId); - } - for (const blockId of this._readBlockIdsFromOp(op)) { - if (scopeSet.has(blockId)) { - mutatedExistingBlockIds.add(blockId); - } else if (!createdBlockIds.has(blockId) && op.type !== "insert-block") { - outOfScopeMutations.add(blockId); - } - } - } - - if (outOfScopeMutations.size > 0) { - return { - valid: false, - reason: `flow-patch-mutated-outside-scope:${[...outOfScopeMutations].join(",")}`, - untouchedBlockMutationCount: 0, - }; - } - - const untouchedBlockMutationCount = [...mutatedExistingBlockIds].filter( - (blockId) => !targetedBlockIds.has(blockId), - ).length; - return { - valid: untouchedBlockMutationCount === 0, - reason: - untouchedBlockMutationCount > 0 - ? "flow-patch-mutated-untargeted-blocks" - : undefined, - untouchedBlockMutationCount, - }; - } - - private _buildMarkdownScopedReplacementOps( - blockIds: readonly string[], - text: string, - ): DocumentOp[] { - const startBlockId = blockIds[0]; - if (!startBlockId) { - return []; - } - const { ops } = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: text, - position: { before: startBlockId }, - surface: "ai-markdown-fast-apply", - }); - return [ - ...ops, - ...blockIds.map((currentBlockId) => ({ - type: "delete-block", - blockId: currentBlockId, - }) satisfies DocumentOp), - ]; - } - - private _summarizeFastApplyFallbackOps( - kind: "scoped-replacement" | "plain-markdown", - ops: readonly DocumentOp[], - targetBlockCount?: number, - ): { - kind: "scoped-replacement" | "plain-markdown"; - opsCount: number; - insertedBlockCount: number; - deletedBlockCount: number; - targetBlockCount?: number; - } { - let insertedBlockCount = 0; - let deletedBlockCount = 0; - for (const op of ops) { - if (op.type === "insert-block") { - insertedBlockCount += 1; - } else if (op.type === "delete-block") { - deletedBlockCount += 1; - } - } - return { - kind, - opsCount: ops.length, - insertedBlockCount, - deletedBlockCount, - targetBlockCount, - }; - } - - private _readBlockIdsFromOp(op: DocumentOp): string[] { - const blockIds = new Set(); - if ("blockId" in op && typeof op.blockId === "string") { - blockIds.add(op.blockId); - } - if ("targetBlockId" in op && typeof op.targetBlockId === "string") { - blockIds.add(op.targetBlockId); - } - if ("sourceBlockId" in op && typeof op.sourceBlockId === "string") { - blockIds.add(op.sourceBlockId); - } - return [...blockIds]; - } - - private _recordFastApplyDebug( - overrides: Partial["fastApply"]>>, - ): void { - const activeGeneration = this._state.activeGeneration; - if (!activeGeneration?.debug) { - return; - } - const currentFastApply = activeGeneration.debug.fastApply ?? { - attempted: false, - succeeded: false, - }; - this._resolveActiveGeneration({ - debug: { - ...activeGeneration.debug, - fastApply: { - ...currentFastApply, - ...overrides, - }, - }, - }); - } - - private _applySuggestedMarkdownPlaceholderReplacement( - blockId: string, - text: string, - sessionId?: string, - replaceTargetBlock?: boolean, - replaceBlockIds?: readonly string[], - ): DocumentOp[] | null { - const targetBlock = this._editor.getBlock(blockId); - if ( - !replaceTargetBlock && - !shouldReplaceEmptyMarkdownTarget(targetBlock) - ) { - return null; - } - - const { ops } = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: text, - position: { before: blockId }, - surface: "ai-markdown", - }); - if (ops.length === 0) { - return null; - } - - const deleteBlockIds = resolveReplacementDeleteBlockIds( - this._editor, - blockId, - replaceBlockIds, - ); - const replacementOps = [ - ...ops, - ...deleteBlockIds.map((nextBlockId) => ({ - type: "delete-block" as const, - blockId: nextBlockId, - })), - ] satisfies DocumentOp[]; - this._applySuggestedAIOps(replacementOps, sessionId); - return replacementOps; - } - - private _refreshStreamingMarkdownBlockPreview( - blockId: string, - text: string, - mutationMode: NonNullable, - sessionId: string | undefined, - baselineSuggestionIds: ReadonlySet, - previewSuggestionIds: readonly string[], - previousNormalizedText: string, - replaceTargetBlock?: boolean, - replaceBlockIds?: readonly string[], - ): { suggestionIds: string[]; normalizedText: string } { - const normalizedText = normalizeFlowMarkdownOutput(text); - if (normalizedText === previousNormalizedText) { - return { - suggestionIds: [...previewSuggestionIds], - normalizedText, - }; - } - - this._rejectPreviewSuggestions(previewSuggestionIds); - - if ( - normalizedText.trim().length === 0 && - !replaceTargetBlock && - (replaceBlockIds?.length ?? 0) === 0 - ) { - return { - suggestionIds: [], - normalizedText, - }; - } - - this._commitBufferedBlockGeneration( - blockId, - normalizedText, - mutationMode, - "markdown", - sessionId, - { replaceTargetBlock, replaceBlockIds }, - ); - - return { - suggestionIds: this.getSuggestions() - .map((item) => item.id) - .filter((suggestionId) => !baselineSuggestionIds.has(suggestionId)), - normalizedText, - }; - } - - private _commitStructuredPlan( - ops: DocumentOp[], - reviewSafe: boolean, - mutationMode: NonNullable, - adapterId: NonNullable, - blockClass: NonNullable, - transportKind: NonNullable, - ): AIMutationReceipt { - if (ops.length === 0) { - return buildMutationReceipt({ - status: "noop", - ops, - adapterId, - blockClass, - transportKind, - }); - } - - if (mutationMode === "direct-stream") { - this._editor.apply(ops, { origin: "ai", undoGroup: true }); - return buildMutationReceipt({ - status: "applied", - ops, - adapterId, - blockClass, - transportKind, - }); - } - - if (reviewSafe) { - this._applySuggestedAIOps(ops); - return buildMutationReceipt({ - status: "staged_suggestions", - ops, - adapterId, - blockClass, - transportKind, - }); - } - return buildMutationReceipt({ - status: "staged_review", - ops, - adapterId, - blockClass, - transportKind, - }); - } - - private _buildFallbackMutationReceipt(input: { - currentText: string; - suggestionIds: readonly string[]; - reviewItems: readonly StructuralReviewItem[]; - planExecutionIssueCount: number; - adapterId: NonNullable; - blockClass: NonNullable; - transportKind: NonNullable; - }): AIMutationReceipt { - if (input.planExecutionIssueCount > 0) { - return buildMutationReceipt({ - status: "invalid", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - issues: ["The generated mutation plan could not be executed."], - }); - } - if (input.reviewItems.length > 0) { - return buildMutationReceipt({ - status: "staged_review", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - }); - } - if (input.suggestionIds.length > 0) { - return buildMutationReceipt({ - status: "staged_suggestions", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - }); - } - return buildMutationReceipt({ - status: input.currentText.trim().length > 0 ? "applied" : "noop", - adapterId: input.adapterId, - blockClass: input.blockClass, - transportKind: input.transportKind, - }); - } - - private async _buildWorkingSet( - toolRuntime: ToolRuntime, - route: ReturnType, - target: GenerationTarget, - blockId: string, - prompt: string, - ): Promise { - const selectionSignature = this._createSelectionSignature(this._editor.selection); - if (target.type === "selection") { - const trackedBlockIds = [...new Set(target.selection.toRange().blockRange)]; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "selection", - routeConfidence: route.confidence, - context: { - selection: target.selection, - selectedText: resolveSelectionText(this._editor, target.selection), - }, - trackedBlockIds, - blockRevisions: this._captureBlockRevisions(trackedBlockIds), - selectionSignature, - }; - } - - if (route.useCursorContext) { - const retrievedSpan = await this._resolveMarkdownFastApplyRetrievedSpan( - toolRuntime, - route, - blockId, - prompt, - ); - if (route.applyStrategy === "markdown-fast-apply" && retrievedSpan) { - const context = await toolRuntime.executeTool( - "get_context", - { - format: "markdown", - includeSelection: true, - includeSuggestions: this._state.suggestMode, - range: retrievedSpan.range, - }, - {} as never, - ) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "cursor-context", - context: { - ...context, - retrievedSpan, - }, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: retrievedSpan.blockIds.length, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(retrievedSpan.blockIds)], - blockRevisions: this._captureBlockRevisions(retrievedSpan.blockIds), - selectionSignature, - }; - } - const context = await toolRuntime.executeTool( - "get_cursor_context", - { includeSuggestions: this._state.suggestMode }, - {} as never, - ) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - const trackedBlockIds = [ - blockId, - ...(context.surroundingBlocks ?? []).map((block) => block.id), - ]; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "cursor-context", - context, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: context.surroundingBlocks?.length ?? 0, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(trackedBlockIds)], - blockRevisions: this._captureBlockRevisions(trackedBlockIds), - selectionSignature, - }; - } - - if (route.useDocumentSummary) { - const retrievedSpan = await this._resolveMarkdownFastApplyRetrievedSpan( - toolRuntime, - route, - blockId, - prompt, - ); - if (route.applyStrategy === "markdown-fast-apply" && retrievedSpan) { - const context = await toolRuntime.executeTool( - "get_context", - { - format: "markdown", - includeSelection: true, - includeSuggestions: this._state.suggestMode, - range: retrievedSpan.range, - }, - {} as never, - ) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "document-summary", - context: { - ...context, - retrievedSpan, - }, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: retrievedSpan.blockIds.length, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(retrievedSpan.blockIds)], - blockRevisions: this._captureBlockRevisions(retrievedSpan.blockIds), - selectionSignature, - }; - } - const context = await toolRuntime.executeTool( - "get_context", - { - format: "markdown", - includeSelection: true, - includeSuggestions: this._state.suggestMode, - range: { - startBlockId: blockId, - endBlockId: blockId, - }, - }, - {} as never, - ) as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - const trackedBlockIds = [ - blockId, - ...(context.surroundingBlocks ?? []).map((block) => block.id), - ]; - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "document-summary", - context, - routeConfidence: refineRouteWithNavigator(route, { - surroundingBlockCount: context.surroundingBlocks?.length ?? 0, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, - }).confidence, - trackedBlockIds: [...new Set(trackedBlockIds)], - blockRevisions: this._captureBlockRevisions(trackedBlockIds), - selectionSignature, - }; - } - - return { - documentVersion: this._documentVersion, - viewMode: this._state.suggestMode ? "raw" : "resolved", - source: "document-summary", - context: null, - routeConfidence: route.confidence, - trackedBlockIds: [blockId], - blockRevisions: this._captureBlockRevisions([blockId]), - selectionSignature, - }; - } - - private _refineRouteWithWorkingSet( - route: ReturnType, - workingSet: AIWorkingSetEnvelope | null, - ): ReturnType { - if (!workingSet?.context || typeof workingSet.context !== "object") { - return route; - } - const context = workingSet.context as { - activeBlockType?: string | null; - markdown?: string | null; - surroundingBlocks?: Array<{ id: string }>; - selectedText?: string | null; - structuredTarget?: { - target?: { - kind?: "block" | "table" | "database"; - }; - } | null; - }; - return refineRouteWithNavigator(route, { - surroundingBlockCount: context.surroundingBlocks?.length ?? 0, - selectedTextLength: context.selectedText?.length ?? 0, - activeBlockType: context.activeBlockType ?? null, - structuredTargetKind: context.structuredTarget?.target?.kind ?? null, - }); - } - - private _validateWorkingSet( - route: ReturnType, - target: GenerationTarget, - workingSet: AIWorkingSetEnvelope | null, - ): { valid: boolean; canRefresh: boolean; reason?: string } { - if (!workingSet) { - return { valid: true, canRefresh: false }; - } - - const selectionSignature = this._createSelectionSignature(this._editor.selection); - const selectionChanged = workingSet.selectionSignature !== selectionSignature; - const revisionChanged = - workingSet.documentVersion !== this._documentVersion || - workingSet.trackedBlockIds.some( - (blockId) => - this._editor.getBlockRevision(blockId) !== - workingSet.blockRevisions[blockId], - ); - - if (!selectionChanged && !revisionChanged) { - return { valid: true, canRefresh: false }; - } - - if (route.lane === "selection-rewrite" || route.lane === "cursor-context") { - return { - valid: false, - canRefresh: false, - reason: selectionChanged - ? "selection-provenance-changed" - : "local-context-changed", - }; - } - - return { - valid: false, - canRefresh: target.type === "block", - reason: revisionChanged ? "document-revision-mismatch" : "selection-changed", - }; - } - - private _resolveMarkdownFastApplyWindow( - route: ReturnType, - blockId: string, - ): { - range: { startBlockId: string; endBlockId: string }; - blockIds: string[]; - } | null { - const blocks = Array.from(this._editor.blocks()); - const blockIndex = blocks.findIndex((block) => block.id === blockId); - if (blockIndex === -1) { - return null; - } - - const radius = - route.targetKind === "table" - ? 0 - : route.intent === "continue" - ? 0 - : route.intent === "rewrite" || route.intent === "local-edit" - ? 1 - : 0; - const startIndex = Math.max(0, blockIndex - radius); - const endIndex = Math.min(blocks.length - 1, blockIndex + radius); - const blockIds = blocks - .slice(startIndex, endIndex + 1) - .map((block) => block.id); - return { - range: { - startBlockId: blockIds[0] ?? blockId, - endBlockId: blockIds[blockIds.length - 1] ?? blockId, - }, - blockIds, - }; - } - - private async _resolveMarkdownFastApplyRetrievedSpan( - toolRuntime: ToolRuntime, - route: ReturnType, - blockId: string, - prompt: string, - ): Promise { - if (route.applyStrategy !== "markdown-fast-apply") { - return null; - } - - try { - const retrieved = await toolRuntime.executeTool( - "retrieve_document_spans", - { - query: prompt, - maxResults: 1, - includeSuggestions: this._state.suggestMode, - activeBlockId: blockId, - targetBlockId: blockId, - }, - {} as never, - ) as { - spans?: AIWorkingSetRetrievedSpan[]; - }; - const retrievedSpan = retrieved.spans?.[0] ?? null; - if (retrievedSpan?.blockIds?.length) { - return retrievedSpan; - } - } catch { - // Older test fixtures or stale builds may not register the retriever yet. - } - - const markdownWindow = this._resolveMarkdownFastApplyWindow(route, blockId); - if (!markdownWindow) { - return null; - } - return { - id: `span:${markdownWindow.blockIds.join(":")}`, - blockIds: markdownWindow.blockIds, - range: markdownWindow.range, - blockTypes: [], - headingPath: [], - preview: "", - markdown: "", - score: 0, - rationale: "window-fallback", - neighbors: { - beforeBlockId: null, - afterBlockId: null, - }, - }; - } - - private _applySuggestedAIOps( - ops: DocumentOp[], - sessionId?: string, - options?: { undoGroupId?: string }, - ): void { - const session = - sessionId != null - ? this._state.sessions.find((item) => item.id === sessionId) ?? null - : null; - const activeGeneration = this._state.activeGeneration; - const undoGroupId = - options?.undoGroupId ?? - (session?.surface === "bottom-chat" && - activeGeneration != null && - activeGeneration.sessionId === sessionId - ? activeGeneration.undoGroupId - : undefined); - if (this._state.suggestMode && !sessionId) { - this._editor.apply(ops, { - origin: "ai", - ...(undoGroupId - ? { undoGroupId } - : { undoGroup: true }), - }); - return; - } - - const intercepted = interceptApplyForSuggestMode( - ops, - this._editor, - this._author, - "ai", - readModelId(this._model), - sessionId, - ); - const origin = sessionId ? AI_SESSION_SUGGESTION_ORIGIN : "extension"; - this._editor.apply(intercepted, { - origin, - ...(undoGroupId - ? { undoGroupId } - : { undoGroup: true }), - }); - } - - private _captureBlockRevisions(blockIds: string[]): Record { - return Object.fromEntries( - blockIds.map((trackedBlockId) => [ - trackedBlockId, - this._editor.getBlockRevision(trackedBlockId), - ]), - ); - } - - private _resolveContentFormat( - target: GenerationState["target"], - surface?: AISurface, - ): AIContentFormat { - if (target === "selection") { - return this._contentFormat.selectionRewrite; - } - return this._contentFormat.blockGeneration; - } - - private _buildTextBlockGenerationOps( - blockId: string, - text: string, - insertionOffset?: number, - ): DocumentOp[] { - const targetBlock = this._editor.getBlock(blockId); - const normalizedText = shouldTrimLeadingBlankBlockGenerationText(targetBlock) - ? trimLeadingBlankBlockGenerationText(text) - : text; - if (normalizedText.length === 0) { - return []; - } - return [{ - type: "insert-text", - blockId, - offset: insertionOffset ?? targetBlock?.textContent().length ?? 0, - text: normalizedText, - }]; - } - - private _buildMarkdownBlockGenerationOps( - blockId: string, - text: string, - replaceTargetBlock?: boolean, - replaceBlockIds?: readonly string[], - ): DocumentOp[] { - const targetBlock = this._editor.getBlock(blockId); - if (!targetBlock) { - return []; - } - - const { ops } = buildDocumentWriteOps(this._editor, { - format: "markdown", - content: text, - position: { after: blockId }, - surface: "ai-markdown", - }); - if ( - !replaceTargetBlock && - !shouldReplaceEmptyMarkdownTarget(targetBlock) - ) { - return ops; - } - - const deleteBlockIds = resolveReplacementDeleteBlockIds( - this._editor, - blockId, - replaceBlockIds, - ); - return [ - ...ops, - ...deleteBlockIds.map((nextBlockId) => ({ - type: "delete-block" as const, - blockId: nextBlockId, - })), - ]; - } - - private _createSelectionSignature(selection: SelectionState): string | null { - if (!selection) { - return null; - } - if (selection.type === "text") { - return [ - "text", - selection.anchor.blockId, - selection.anchor.offset, - selection.focus.blockId, - selection.focus.offset, - String(selection.isCollapsed), - ].join(":"); - } - if (selection.type === "block") { - return `block:${selection.blockIds.join(",")}`; - } - if (selection.type === "cell") { - return [ - "cell", - selection.blockId, - selection.anchor.row, - selection.anchor.col, - selection.head.row, - selection.head.col, - ].join(":"); - } - return selection.type; - } - - private _setState(partial: Partial): void { - const previousState = this._state; - const nextState = { ...this._state, ...partial }; - if (areAIControllerStatesEqual(previousState, nextState)) { - return; - } - this._state = nextState; - if (!this._isRestoringInlineHistory && !this._pendingInlineHistoryRestore) { - this._recordInlineHistorySnapshot(previousState, nextState); - } - this._editor.requestDecorationUpdate(); - this._emit(); - } - - private _resolveActiveGeneration( - overrides: Partial, - ): void { - const activeGeneration = this._state.activeGeneration; - if (!activeGeneration) { - return; - } - - this._setState({ - activeGeneration: { - ...activeGeneration, - ...overrides, - plan: - overrides.planState === "none" || overrides.planState === "rejected" - ? null - : (overrides.plan ?? activeGeneration.plan), - reviewItems: - overrides.planState === "none" || overrides.planState === "rejected" - ? [] - : (overrides.reviewItems ?? activeGeneration.reviewItems ?? []), - structuredPreview: - overrides.planState === "none" || overrides.planState === "rejected" - ? null - : (overrides.structuredPreview ?? activeGeneration.structuredPreview ?? null), - suggestionIds: - overrides.suggestionIds ?? activeGeneration.suggestionIds ?? [], - }, - }); - } - - private _resolveSessionTurn( - sessionId: string, - turnId: string, - resolution: AISessionResolution, - options?: { finalizeSession?: boolean }, - ): boolean { - const session = this._state.sessions.find((item) => item.id === sessionId); - const turn = session?.turns.find((item) => item.id === turnId); - if (!session || !turn) { - return false; - } - const isBottomChatDocumentTurn = - session.surface === "bottom-chat" && - (turn.target === "document" || - turn.operation?.kind === "document-transform" || - ( - turn.operation?.kind === "rewrite-selection" && - turn.operation.target.kind === "scoped-range" && - (turn.operation.target.scope === "document" || - turn.operation.target.contentFormat === "markdown") - )); - const turnUndoGroupId = isBottomChatDocumentTurn - ? turn.undoGroupId - : undefined; - const turnSuggestionResolutionOrigin = - turnUndoGroupId != null ? AI_SESSION_SUGGESTION_ORIGIN : undefined; - const undoHistoryBeforeSnapshot = this._undoHistoryMetadata - ? this._createInlineTurnUndoBeforeSnapshot(sessionId, turnId) - : null; - const refreshedInlineSelectionTarget = - session.surface === "inline-edit" && resolution === "accept" - ? resolveAcceptedInlineSelectionTarget( - this._editor, - turn.operation, - turn.suggestionIds, - ) ?? resolveLiveInlineSelectionTarget(this._editor) - : null; - const resolveSuggestionsForTurn = - resolution === "accept" - ? (suggestionIds: readonly string[]) => - acceptSuggestions(this._editor, suggestionIds, { - origin: turnSuggestionResolutionOrigin, - undoGroupId: turnUndoGroupId, - }) - : (suggestionIds: readonly string[]) => - rejectSuggestions(this._editor, suggestionIds, { - origin: turnSuggestionResolutionOrigin, - undoGroupId: turnUndoGroupId, - }); - const resolveReviewItems = - resolution === "accept" - ? (reviewItemIds: readonly string[]) => this.acceptReviewItems(reviewItemIds) - : (reviewItemIds: readonly string[]) => this.rejectReviewItems(reviewItemIds); - let resolved = false; - resolved = - resolveSuggestionsForTurn(turn.suggestionIds) || resolved; - if ( - this._state.activeGeneration?.sessionId === sessionId && - this._state.activeGeneration.turnId === turnId && - this._state.activeGeneration.planState === "validated" && - turn.reviewItemIds.length > 0 - ) { - resolved = resolveReviewItems(turn.reviewItemIds) || resolved; - } - if (!resolved) { - return false; - } - this._updateSessionTurn(sessionId, turnId, { - status: resolution === "accept" ? "accepted" : "rejected", - suggestionIds: [], - reviewItemIds: [], - structuredPreview: null, - anchor: refreshedInlineSelectionTarget - ? resolveSessionAnchor(refreshedInlineSelectionTarget.selection) - : undefined, - selection: refreshedInlineSelectionTarget - ? resolveSessionSelectionSnapshot( - refreshedInlineSelectionTarget.selection, - ) - : undefined, - }); - if (refreshedInlineSelectionTarget) { - this._updateSession(sessionId, { - target: refreshedInlineSelectionTarget, - anchor: resolveSessionAnchor(refreshedInlineSelectionTarget.selection), - contextualPrompt: session.contextualPrompt - ? { - ...session.contextualPrompt, - anchor: resolveContextualPromptAnchor(refreshedInlineSelectionTarget), - } - : undefined, - }); - } - if (options?.finalizeSession === false) { - if (undoHistoryBeforeSnapshot) { - this._undoHistoryMetadata?.setCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - { - before: undoHistoryBeforeSnapshot, - after: createInlineHistorySnapshot( - this._editor, - this._state.sessions, - this._state.activeSessionId ?? null, - this._documentVersion, - { kind: "document-coupled" }, - ), - }, - ); - } - return true; - } - const nextSession = - this._state.sessions.find((item) => item.id === sessionId) ?? session; - this._updateSession(sessionId, { - status: "complete", - contextualPrompt: closeInlineSessionPrompt(nextSession), - }); - if (undoHistoryBeforeSnapshot) { - this._undoHistoryMetadata?.setCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - { - before: undoHistoryBeforeSnapshot, - after: createInlineHistorySnapshot( - this._editor, - this._state.sessions, - this._state.activeSessionId ?? null, - this._documentVersion, - { kind: "document-coupled" }, - ), - }, - ); - } - return true; - } - - private _createInlineTurnUndoBeforeSnapshot( - sessionId: string, - turnId: string, - ): AIInlineHistorySnapshot { - const session = - this._state.sessions.find((item) => item.id === sessionId) ?? null; - if (session?.surface === "inline-edit") { - const reviewSnapshot = this._findInlineHistorySnapshotForResolvedTurn( - session, - "undo", - ); - if (reviewSnapshot) { - const restoredSessions = reviewSnapshot.sessions.map((snapshotSession) => { - if ( - snapshotSession.id !== sessionId || - snapshotSession.surface !== "inline-edit" || - !snapshotSession.contextualPrompt - ) { - return snapshotSession; - } - const snapshotTurn = - snapshotSession.turns.find((turn) => turn.id === turnId) ?? null; - if (!snapshotTurn) { - return snapshotSession; - } - return { - ...snapshotSession, - contextualPrompt: { - ...snapshotSession.contextualPrompt, - composer: { - ...snapshotSession.contextualPrompt.composer, - draftPrompt: - snapshotSession.contextualPrompt.composer.draftPrompt || - snapshotTurn.prompt, - }, - }, - }; - }); - return createInlineHistorySnapshot( - this._editor, - restoredSessions, - sessionId, - this._documentVersion, - { kind: "document-coupled" }, - ); - } - } - const historySessions = this._state.sessions.map((session) => { - if ( - session.id !== sessionId || - session.surface !== "inline-edit" || - !session.contextualPrompt - ) { - return session; - } - const targetTurn = session.turns.find((turn) => turn.id === turnId) ?? null; - if (targetTurn?.status !== "review") { - return session; - } - return { - ...session, - contextualPrompt: { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: true, - isSubmitting: false, - }, - }, - }; - }); - const nextActiveSessionId = historySessions.some( - (session) => - session.id === sessionId && - session.surface === "inline-edit" && - session.contextualPrompt?.composer.isOpen, - ) - ? sessionId - : (this._state.activeSessionId ?? null); - return createInlineHistorySnapshot( - this._editor, - historySessions, - nextActiveSessionId, - this._documentVersion, - { kind: "document-coupled" }, - ); - } - - private _updateSession( - sessionId: string, - overrides: Partial, - ): void { - const nextSessions = this._state.sessions.map((session) => - session.id !== sessionId - ? session - : { - ...session, - ...overrides, - contextualPrompt: - overrides.contextualPrompt ?? session.contextualPrompt - ? { - ...(session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )), - ...(overrides.contextualPrompt ?? {}), - anchor: { - ...((session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )).anchor), - ...(overrides.contextualPrompt?.anchor ?? {}), - }, - composer: { - ...((session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )).composer), - ...(overrides.contextualPrompt?.composer ?? {}), - isSubmitting: - overrides.contextualPrompt?.composer?.isSubmitting ?? - (overrides.status === "streaming" - ? true - : overrides.status - ? false - : (session.contextualPrompt ?? - resolveContextualPromptState( - overrides.target ?? session.target, - )).composer.isSubmitting), - }, - } - : undefined, - updatedAt: Date.now(), - metrics: { - ...session.metrics, - ...(overrides.metrics ?? {}), - }, - }, - ); - if (nextSessions === this._state.sessions) { - return; - } - this._setState({ - sessions: nextSessions, - activeSessionId: - this._state.activeSessionId === sessionId || this._state.activeSessionId == null - ? sessionId - : this._state.activeSessionId, - }); - } - - private _recordSessionFastApplyMetrics( - sessionId: string, - fastApply: FastApplyDebugState | undefined, - ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session) { - return; - } - this._updateSession(sessionId, { - metrics: { - ...session.metrics, - fastApply: accumulateSessionFastApplyMetrics( - session.metrics.fastApply, - fastApply, - ), - }, - }); - } - - private _updateSessionTurn( - sessionId: string, - turnId: string, - overrides: Partial, - ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); - if (!session) { - return; - } - const nextTurns = session.turns.map((turn) => - turn.id !== turnId - ? turn - : { - ...turn, - ...overrides, - }, - ); - if (areStructuredValuesEqual(session.turns, nextTurns)) { - return; - } - const pendingSuggestionIds = [ - ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), - ]; - const pendingReviewItemIds = [ - ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), - ]; - this._updateSession(sessionId, { - turns: nextTurns, - pendingSuggestionIds, - pendingReviewItemIds, - }); - } - - private _syncSessionsFromDocument(): boolean { - if (this._state.sessions.length === 0) { - return false; - } - const nextSessions = this._state.sessions.map((session) => { - const nextTurns = session.turns.map((turn) => { - const suggestionIds = turn.suggestionIds.filter((sessionSuggestionId) => - this._suggestions.some((suggestion) => suggestion.id === sessionSuggestionId), - ); - const activeGenerationMatchesTurn = - this._state.activeGeneration?.sessionId === session.id && - this._state.activeGeneration.turnId === turn.id; - const activeGenerationForTurn = activeGenerationMatchesTurn - ? this._state.activeGeneration - : null; - const reviewItemIds = - activeGenerationForTurn - ? (activeGenerationForTurn.reviewItems ?? []) - .map((item) => item.id) - .filter((id) => turn.reviewItemIds.includes(id)) - : []; - const structuredPreview = activeGenerationForTurn - ? (activeGenerationForTurn.structuredPreview ?? turn.structuredPreview ?? null) - : (turn.reviewItemIds.length > 0 ? (turn.structuredPreview ?? null) : null); - return { - ...turn, - suggestionIds, - reviewItemIds, - structuredPreview, - }; - }); - const pendingSuggestionIds = [...new Set(nextTurns.flatMap((turn) => turn.suggestionIds))]; - const pendingReviewItemIds = - [...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds))]; - const nextStatus = - pendingSuggestionIds.length === 0 && - pendingReviewItemIds.length === 0 && - session.status === "streaming" - ? "complete" - : session.status; - return { - ...session, - status: nextStatus, - turns: nextTurns, - pendingSuggestionIds, - pendingReviewItemIds, - }; - }); - if (areSessionsEqual(this._state.sessions, nextSessions)) { - return false; - } - this._setState({ - sessions: nextSessions, - }); - return true; - } - - private _setStreamEvents(nextEvents: readonly AIStreamEvent[]): void { - this._streamEvents = nextEvents; - this._emitStreamEvents(); - } - - private _appendStreamEvent(event: AIStreamEvent): void { - const lastEvent = this._streamEvents[this._streamEvents.length - 1]; - if ( - lastEvent?.type === "status" && - event.type === "status" && - lastEvent.generationId === event.generationId && - lastEvent.status === event.status - ) { - return; - } - const nextEvents = - this._streamEvents.length >= MAX_STREAM_EVENTS - ? [...this._streamEvents.slice(-(MAX_STREAM_EVENTS - 1)), event] - : [...this._streamEvents, event]; - this._setStreamEvents(nextEvents); - } - - private _emit(): void { - for (const listener of this._listeners) { - listener(); - } - for (const listener of this._sessionListeners) { - listener(); - } - } - - private _emitStreamEvents(): void { - for (const listener of this._streamEventListeners) { - listener(); - } - } - - private _syncSuggestionsFromDocument(): boolean { - const nextSuggestions = readAllSuggestions(this._editor); - if (areSuggestionsEqual(this._suggestions, nextSuggestions)) { - return false; - } - this._suggestions = nextSuggestions; - return true; - } - - private _recordInlineHistorySnapshot( - previousState: AIControllerState, - nextState: AIControllerState, - ): void { - if ( - !didInlineHistoryCheckpointChange(previousState, nextState) - ) { - return; - } - if ( - previousState.sessions === nextState.sessions && - previousState.activeSessionId === nextState.activeSessionId - ) { - return; - } - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; - const nextHistory = this._inlineHistory.slice(0, this._inlineHistoryIndex + 1); - if (nextHistory.length === 0) { - const baselineSnapshot = createInlineHistorySnapshot( - this._editor, - previousState.sessions, - previousState.activeSessionId ?? null, - this._documentVersion, - ); - nextHistory.push(baselineSnapshot); - } - const previousSnapshot = nextHistory[nextHistory.length - 1] ?? currentSnapshot ?? null; - const snapshot = createInlineHistorySnapshot( - this._editor, - nextState.sessions, - nextState.activeSessionId ?? null, - this._documentVersion, - { - kind: - previousSnapshot?.documentVersion === this._documentVersion - ? "ui-local" - : "document-coupled", - }, - ); - if (currentSnapshot && areInlineHistorySnapshotsEqual(currentSnapshot, snapshot)) { - return; - } - const currentUndoMetadata = - this._undoHistoryMetadata?.getCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - ) ?? null; - const shouldPersistUndoSnapshot = - previousSnapshot != null && - (snapshot.kind === "document-coupled" || - currentUndoMetadata?.after?.documentVersion === this._documentVersion); - if (shouldPersistUndoSnapshot && previousSnapshot) { - this._undoHistoryMetadata?.setCurrentEntryMetadata( - AI_UNDO_HISTORY_METADATA_KEY, - { - before: currentUndoMetadata?.before ?? previousSnapshot, - after: snapshot, - }, - ); - } - nextHistory.push(snapshot); - this._inlineHistory = nextHistory; - this._inlineHistoryIndex = nextHistory.length - 1; - } - - private _recordInlinePromptSubmissionCheckpoint( - sessionId: string, - prompt: string, - ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); - if ( - !session || - session.surface !== "inline-edit" || - !session.contextualPrompt - ) { - return; - } - const checkpointState: AIControllerState = { - ...this._state, - activeSessionId: sessionId, - sessions: this._state.sessions.map((item) => - item.id !== sessionId - ? item - : { - ...item, - contextualPrompt: { - ...item.contextualPrompt!, - composer: { - ...item.contextualPrompt!.composer, - draftPrompt: prompt, - isOpen: true, - isSubmitting: false, - }, - }, - }, - ), - }; - const snapshot = createInlineHistorySnapshot( - this._editor, - checkpointState.sessions, - checkpointState.activeSessionId ?? null, - this._documentVersion, - { kind: "ui-local" }, - ); - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; - if (currentSnapshot && areInlineHistorySnapshotsEqual(currentSnapshot, snapshot)) { - return; - } - const nextHistory = this._inlineHistory.slice(0, this._inlineHistoryIndex + 1); - nextHistory.push(snapshot); - this._inlineHistory = nextHistory; - this._inlineHistoryIndex = nextHistory.length - 1; - } - - private _resolveInlineHistoryTargetIndex( - direction: AIInlineHistoryDirection, - options?: { shortcutOnly?: boolean }, - ): number { - const step = direction === "undo" ? -1 : 1; - if (!options?.shortcutOnly) { - return this._inlineHistoryIndex + step; - } - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex] ?? null; - const scopedSessionId = this._resolveShortcutInlineHistorySessionId( - currentSnapshot, - direction, - ); - const waypoints = this._buildInlineShortcutHistoryWaypoints(scopedSessionId); - if (waypoints.length === 0) { - return -1; - } - const currentWaypointIndex = this._resolveCurrentInlineShortcutWaypointIndex( - waypoints, - scopedSessionId, - ); - if (currentWaypointIndex < 0) { - return -1; - } - const targetWaypoint = waypoints[currentWaypointIndex + step]; - return targetWaypoint?.representativeIndex ?? -1; - } - - private _resolveShortcutInlineHistorySessionId( - currentSnapshot: AIInlineHistorySnapshot | null, - direction: AIInlineHistoryDirection, - ): string | null { - const activeSession = this.getActiveSession(); - if (activeSession?.surface === "inline-edit") { - return activeSession.id; - } - const selection = this._editor.selection; - if (currentSnapshot && selection?.type === "text" && !selection.isCollapsed) { - const matchingSession = [...currentSnapshot.sessions] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - sessionSelectionMatches(session, selection), - ); - if (matchingSession) { - return matchingSession.id; - } - } - if ( - currentSnapshot?.activeSessionId && - currentSnapshot.sessions.some( - (session) => - session.id === currentSnapshot.activeSessionId && - session.surface === "inline-edit", - ) - ) { - return currentSnapshot.activeSessionId; - } - const currentInlineSession = - [...(currentSnapshot?.sessions ?? [])] - .reverse() - .find((session) => session.surface === "inline-edit") ?? null; - if (currentInlineSession) { - return currentInlineSession.id; - } - const step = direction === "undo" ? -1 : 1; - let searchIndex = this._inlineHistoryIndex + step; - while (searchIndex >= 0 && searchIndex < this._inlineHistory.length) { - const searchSnapshot = this._inlineHistory[searchIndex]; - const matchingSelectionSession = - selection?.type === "text" && !selection.isCollapsed - ? [...(searchSnapshot?.sessions ?? [])] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - sessionSelectionMatches(session, selection), - ) ?? null - : null; - if (matchingSelectionSession) { - return matchingSelectionSession.id; - } - const searchInlineSession = - [...(searchSnapshot?.sessions ?? [])] - .reverse() - .find((session) => session.surface === "inline-edit") ?? null; - if (searchInlineSession) { - return searchInlineSession.id; - } - searchIndex += step; - } - return null; - } - - private _buildInlineShortcutHistoryWaypoints( - sessionId: string | null, - ): AIInlineShortcutHistoryWaypoint[] { - const waypoints: AIInlineShortcutHistoryWaypoint[] = []; - for (let index = 0; index < this._inlineHistory.length; index += 1) { - const snapshot = this._inlineHistory[index]; - if (!snapshot || snapshot.kind === "ui-local") { - continue; - } - const state = resolveInlineShortcutHistoryState(snapshot, sessionId); - if (!state) { - continue; - } - const previousWaypoint = waypoints[waypoints.length - 1] ?? null; - if ( - previousWaypoint && - areInlineShortcutHistoryStatesEqual(previousWaypoint.state, state) - ) { - previousWaypoint.endIndex = index; - if ( - shouldReplaceInlineShortcutWaypointRepresentative( - previousWaypoint.state, - this._inlineHistory[previousWaypoint.representativeIndex] ?? null, - snapshot, - ) - ) { - previousWaypoint.representativeIndex = index; - } - continue; - } - waypoints.push({ - startIndex: index, - endIndex: index, - representativeIndex: index, - state, - }); - } - return waypoints; - } - - private _resolveCurrentInlineShortcutWaypointIndex( - waypoints: readonly AIInlineShortcutHistoryWaypoint[], - sessionId: string | null, - ): number { - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex] ?? null; - const currentState = currentSnapshot - ? resolveInlineShortcutHistoryState(currentSnapshot, sessionId) - : null; - if (currentState) { - const currentIndex = waypoints.findIndex( - (waypoint) => - this._inlineHistoryIndex >= waypoint.startIndex && - this._inlineHistoryIndex <= waypoint.endIndex && - areInlineShortcutHistoryStatesEqual(waypoint.state, currentState), - ); - if (currentIndex >= 0) { - return currentIndex; - } - const matchingIndex = waypoints.findIndex((waypoint) => - areInlineShortcutHistoryStatesEqual(waypoint.state, currentState), - ); - if (matchingIndex >= 0) { - return matchingIndex; - } - } - for (let index = waypoints.length - 1; index >= 0; index -= 1) { - if (waypoints[index]!.representativeIndex <= this._inlineHistoryIndex) { - return index; - } - } - return waypoints.length > 0 ? 0 : -1; - } - - private _canHandleInlineHistoryShortcut( - direction: AIInlineHistoryDirection, - options?: { shortcutOnly?: boolean }, - ): boolean { - const targetIndex = this._resolveInlineHistoryTargetIndex(direction, options); - const targetSnapshot = this._inlineHistory[targetIndex]; - if (!targetSnapshot) { - return false; - } - if (targetSnapshot.kind !== "ui-local") { - return true; - } - return direction === "undo" - ? !this._editor.undoManager.canUndo() - : !this._editor.undoManager.canRedo(); - } - - private _navigateInlineHistory( - direction: AIInlineHistoryDirection, - options?: { shortcutOnly?: boolean }, - ): boolean { - const targetIndex = this._resolveInlineHistoryTargetIndex(direction, options); - const targetSnapshot = this._inlineHistory[targetIndex]; - if (!targetSnapshot) { - return false; - } - const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex] ?? null; - const shortcutSessionId = options?.shortcutOnly - ? this._resolveShortcutInlineHistorySessionId(currentSnapshot, direction) - : null; - if (targetSnapshot.kind === "ui-local") { - this._applyInlineHistorySnapshot(targetSnapshot, { - historyTraversal: true, - }); - this._inlineHistoryIndex = targetIndex; - return true; - } - if ( - currentSnapshot && - currentSnapshot.documentVersion !== targetSnapshot.documentVersion - ) { - const targetState = resolveInlineShortcutHistoryState( - targetSnapshot, - shortcutSessionId ?? - targetSnapshot.sessionId ?? - targetSnapshot.activeSessionId ?? - null, - ); - this._pendingInlineHistoryRestore = { - direction, - targetSnapshotId: targetSnapshot.id, - targetDocumentVersion: targetSnapshot.documentVersion, - shortcutOnly: options?.shortcutOnly === true, - sessionId: shortcutSessionId, - targetState, - }; - const restored = - direction === "undo" - ? this._editor.undoManager.undo() - : this._editor.undoManager.redo(); - if (!restored) { - this._pendingInlineHistoryRestore = null; - } - return restored; - } - const resolvedTargetSnapshot = options?.shortcutOnly - ? this._resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot, - shortcutSessionId, - ) - : targetSnapshot; - this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { - historyTraversal: true, - }); - this._inlineHistoryIndex = targetIndex; - return true; - } - - private _applyInlineHistorySnapshot( - snapshot: AIInlineHistorySnapshot, - options?: { historyTraversal?: boolean }, - ): void { - this._isRestoringInlineHistory = true; - try { - const restoredSessions = cloneInlineHistorySessions( - this._editor, - snapshot.sessions, - ).map((session) => { - if ( - !options?.historyTraversal || - !session.contextualPrompt?.composer.isOpen - ) { - return session; - } - return { - ...session, - contextualPrompt: { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - openReason: "history" as const, - }, - }, - }; - }); - this._setState({ - status: "idle", - activeGeneration: null, - sessions: restoredSessions, - activeSessionId: snapshot.activeSessionId, - }); - } finally { - this._isRestoringInlineHistory = false; - } - } - - private _restoreInlineHistorySnapshotFromUndo( - snapshot: AIInlineHistorySnapshot, - ): void { - const targetIndex = this._inlineHistory.findIndex( - (item) => item.id === snapshot.id, - ); - if (targetIndex >= 0) { - this._inlineHistoryIndex = targetIndex; - this._applyInlineHistorySnapshot(this._inlineHistory[targetIndex]!, { - historyTraversal: true, - }); - return; - } - this._applyInlineHistorySnapshot(snapshot, { historyTraversal: true }); - const nextHistory = this._inlineHistory.slice(0, this._inlineHistoryIndex + 1); - nextHistory.push(snapshot); - this._inlineHistory = nextHistory; - this._inlineHistoryIndex = nextHistory.length - 1; - } - - private _findInlineHistorySnapshotForResolvedTurn( - session: AISession, - direction: AIInlineHistoryDirection, - ): AIInlineHistorySnapshot | null { - const latestTurnId = session.turns[session.turns.length - 1]?.id ?? null; - if (!latestTurnId) { - return null; - } - for (let index = this._inlineHistory.length - 1; index >= 0; index -= 1) { - const snapshot = this._inlineHistory[index]; - const snapshotSession = - snapshot?.sessions.find( - (item) => item.id === session.id && item.surface === "inline-edit", - ) ?? null; - if (!snapshotSession) { - continue; - } - const snapshotTurn = - snapshotSession.turns.find((turn) => turn.id === latestTurnId) ?? null; - if (!snapshotTurn) { - continue; - } - if ( - direction === "undo" && - snapshotSession.contextualPrompt?.composer.isOpen && - snapshotTurn.status === "review" - ) { - return snapshot; - } - if ( - direction === "redo" && - !snapshotSession.contextualPrompt?.composer.isOpen && - (snapshotTurn.status === "accepted" || - snapshotTurn.status === "rejected") - ) { - return snapshot; - } - } - return null; - } - - private _resolveInlineHistoryTraversalSnapshot( - targetSnapshot: AIInlineHistorySnapshot, - ): AIInlineHistorySnapshot { - if (targetSnapshot.kind === "ui-local") { - return targetSnapshot; - } - const scopedSessionId = - targetSnapshot.sessionId ?? targetSnapshot.activeSessionId; - const targetState = resolveInlineShortcutHistoryState( - targetSnapshot, - scopedSessionId, - ); - if (!targetState) { - return targetSnapshot; - } - let resolvedSnapshot = targetSnapshot; - for (const snapshot of this._inlineHistory) { - if (snapshot.documentVersion !== targetSnapshot.documentVersion) { - continue; - } - const snapshotState = resolveInlineShortcutHistoryState( - snapshot, - scopedSessionId, - ); - if ( - !snapshotState || - !areInlineShortcutHistoryStatesEqual(snapshotState, targetState) - ) { - continue; - } - if ( - shouldReplaceInlineShortcutWaypointRepresentative( - targetState, - resolvedSnapshot, - snapshot, - ) - ) { - resolvedSnapshot = snapshot; - } - } - return resolvedSnapshot; - } - - private _resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot: AIInlineHistorySnapshot, - fallbackSessionId?: string | null, - ): AIInlineHistorySnapshot { - const scopedSessionId = - targetSnapshot.sessionId ?? - targetSnapshot.activeSessionId ?? - fallbackSessionId ?? - null; - const targetState = resolveInlineShortcutHistoryState( - targetSnapshot, - scopedSessionId, - ); - if (targetState?.phase !== "none" || !scopedSessionId) { - return this._resolveInlineHistoryTraversalSnapshot(targetSnapshot); - } - return createInlineHistorySnapshot( - this._editor, - targetSnapshot.sessions.filter((session) => session.id !== scopedSessionId), - targetSnapshot.activeSessionId === scopedSessionId - ? null - : targetSnapshot.activeSessionId, - targetSnapshot.documentVersion, - { kind: targetSnapshot.kind }, - ); - } - - private _scheduleQueuedInlineHistoryShortcutFlush(): void { - if ( - this._queuedInlineHistoryShortcutFlushScheduled || - this._queuedInlineHistoryShortcutDirections.length === 0 - ) { - return; - } - this._queuedInlineHistoryShortcutFlushScheduled = true; - queueMicrotask(() => { - this._queuedInlineHistoryShortcutFlushScheduled = false; - if (this._pendingInlineHistoryRestore) { - this._scheduleQueuedInlineHistoryShortcutFlush(); - return; - } - const nextDirection = - this._queuedInlineHistoryShortcutDirections.shift() ?? null; - if (!nextDirection) { - return; - } - this._navigateInlineHistory(nextDirection, { shortcutOnly: true }); - if (this._queuedInlineHistoryShortcutDirections.length > 0) { - this._scheduleQueuedInlineHistoryShortcutFlush(); - } - }); - } - - private _resolvePendingInlineHistoryRestoreTargetIndex( - request: AIInlineHistoryRestoreRequest, - ): number { - const exactTargetIndex = this._inlineHistory.findIndex( - (snapshot) => snapshot.id === request.targetSnapshotId, - ); - if (exactTargetIndex >= 0) { - return exactTargetIndex; - } - if (!request.targetState) { - return -1; - } - let resolvedTargetIndex = -1; - const scopedSessionId = request.sessionId ?? request.targetState.sessionId; - for (let index = 0; index < this._inlineHistory.length; index += 1) { - const snapshot = this._inlineHistory[index]; - if (!snapshot || snapshot.kind === "ui-local") { - continue; - } - if (snapshot.documentVersion !== request.targetDocumentVersion) { - continue; - } - const snapshotState = resolveInlineShortcutHistoryState( - snapshot, - scopedSessionId ?? null, - ); - if ( - !snapshotState || - !areInlineShortcutHistoryStatesEqual(snapshotState, request.targetState) - ) { - continue; - } - if ( - resolvedTargetIndex < 0 || - shouldReplaceInlineShortcutWaypointRepresentative( - request.targetState, - this._inlineHistory[resolvedTargetIndex] ?? null, - snapshot, - ) - ) { - resolvedTargetIndex = index; - } - } - return resolvedTargetIndex; - } - - private _handleHistoryApplied(event: HistoryAppliedEvent): void { - if ( - this._pendingInlineHistoryRestore && - this._pendingInlineHistoryRestore.direction === event.kind - ) { - const targetIndex = this._resolvePendingInlineHistoryRestoreTargetIndex( - this._pendingInlineHistoryRestore, - ); - if (targetIndex >= 0) { - this._inlineHistoryIndex = targetIndex; - const targetSnapshot = this._inlineHistory[targetIndex]!; - const resolvedTargetSnapshot = - this._pendingInlineHistoryRestore.shortcutOnly - ? this._resolveShortcutInlineHistoryTraversalSnapshot( - targetSnapshot, - this._pendingInlineHistoryRestore.sessionId ?? null, - ) - : this._resolveInlineHistoryTraversalSnapshot(targetSnapshot); - this._applyInlineHistorySnapshot( - resolvedTargetSnapshot, - { - historyTraversal: true, - }, - ); - } - this._pendingInlineHistoryRestore = null; - this._scheduleQueuedInlineHistoryShortcutFlush(); - return; - } - if (this._handledUndoHistoryRequestId === event.requestId) { - this._handledUndoHistoryRequestId = null; - return; - } - const selection = event.selection; - if (selection?.type !== "text" || selection.isCollapsed) { - return; - } - const matchingSession = [...this._state.sessions] - .reverse() - .find( - (session) => - session.surface === "inline-edit" && - session.status !== "cancelled" && - sessionSelectionMatches(session, selection), - ); - if (!matchingSession) { - return; - } - this._setInlineSessionComposerOpen(matchingSession.id, true, { - openReason: "history", - }); - } - - private _setInlineSessionComposerOpen( - sessionId: string, - isOpen: boolean, - options?: { openReason?: "user" | "history" }, - ): void { - const session = this._state.sessions.find((item) => item.id === sessionId); - if ( - !session || - session.surface !== "inline-edit" || - !session.contextualPrompt - ) { - return; - } - const nextActiveSessionId = isOpen - ? sessionId - : this._state.activeSessionId === sessionId - ? null - : this._state.activeSessionId; - if ( - session.contextualPrompt.composer.isOpen === isOpen && - nextActiveSessionId === this._state.activeSessionId - ) { - return; - } - const nextSessions = this._state.sessions.map((item) => - item.id !== sessionId - ? item - : { - ...item, - contextualPrompt: { - ...item.contextualPrompt!, - composer: { - ...item.contextualPrompt!.composer, - isOpen, - openReason: isOpen - ? (options?.openReason ?? "user") - : item.contextualPrompt!.composer.openReason, - }, - }, - updatedAt: Date.now(), - }, - ); - this._setState({ - sessions: nextSessions, - activeSessionId: nextActiveSessionId, - }); - } -} - -export function aiExtension(config: AIExtensionConfig = {}): Extension { - let unsubscribeBeforeApply: (() => void) | null = null; - let unsubscribeTrackedOrigins: (() => void) | null = null; - let controller: AIControllerImpl | null = null; - let inlineCompletion: AIInlineCompletionController | null = null; - let releaseInlineCompletion: (() => void) | null = null; - let inlineHistory: AIInlineHistoryService | null = null; - let reviewController: AIReviewService | null = null; - let activeEditor: Editor | null = null; - - return defineExtension({ - name: AI_EXTENSION_NAME, - dependencies: ["document-ops", "delta-stream", "undo"], - keyBindings: AI_SHORTCUT_KEY_BINDINGS, - - activateClient: async ({ editor }) => { - activeEditor = editor; - const inlineCompletionRegistration = ensureInlineCompletionController(editor); - inlineCompletion = inlineCompletionRegistration.controller; - releaseInlineCompletion = inlineCompletionRegistration.release; - controller = new AIControllerImpl(editor, config, { - inlineCompletion, - }); - inlineHistory = new AIInlineHistoryService({ - canUndoInlineHistory: () => - controller ? controller.canUndoInlineHistory() : false, - canRedoInlineHistory: () => - controller ? controller.canRedoInlineHistory() : false, - canHandleShortcut: (direction) => - controller - ? controller.canHandleInlineHistoryShortcut(direction) - : false, - handleShortcut: (direction) => - controller - ? controller.handleInlineHistoryShortcut(direction) - : false, - undoInlineHistory: () => - controller ? controller.undoInlineHistory() : false, - redoInlineHistory: () => - controller ? controller.redoInlineHistory() : false, - }); - reviewController = new AIReviewService({ - getSuggestions: () => controller?.getSuggestions() ?? [], - acceptSuggestion: (id) => controller?.acceptSuggestion(id) ?? false, - rejectSuggestion: (id) => controller?.rejectSuggestion(id) ?? false, - acceptAllSuggestions: () => controller?.acceptAllSuggestions(), - rejectAllSuggestions: () => controller?.rejectAllSuggestions(), - }); - editor.internals.setSlot(AI_CONTROLLER_SLOT, controller); - editor.internals.setSlot(AI_INLINE_HISTORY_SLOT, inlineHistory); - editor.internals.setSlot(AI_REVIEW_CONTROLLER_SLOT, reviewController); - unsubscribeTrackedOrigins = editor.undoManager.registerTrackedOrigins([ - AI_SESSION_SUGGESTION_ORIGIN, - SUGGESTION_RESOLUTION_ORIGIN, - ]); - - unsubscribeBeforeApply = editor.onBeforeApply( - (ops, options) => { - if (!controller?.getState().suggestMode) return ops; - if (shouldBypassSuggestMode(options.origin)) return ops; - return interceptApplyForSuggestMode( - ops, - editor, - options.origin === "ai" ? "assistant" : config.author ?? "user", - options.origin === "ai" ? "ai" : "user", - readModelId(config.model), - ); - }, - { priority: 200 }, - ); - }, - - deactivateClient: async () => { - controller?.cancelActiveGeneration(); - controller?.destroy(); - activeEditor?.internals.setSlot(AI_CONTROLLER_SLOT, null); - activeEditor?.internals.setSlot(AI_INLINE_HISTORY_SLOT, null); - activeEditor?.internals.setSlot(AI_REVIEW_CONTROLLER_SLOT, null); - releaseInlineCompletion?.(); - unsubscribeTrackedOrigins?.(); - unsubscribeTrackedOrigins = null; - unsubscribeBeforeApply?.(); - unsubscribeBeforeApply = null; - controller = null; - inlineCompletion = null; - releaseInlineCompletion = null; - inlineHistory = null; - reviewController = null; - activeEditor = null; - }, - - observe: (events, editor) => { - if (!controller) { - editor.requestDecorationUpdate(); - return; - } - controller.handleDocumentChange(events); - }, - - decorations: () => { - const decorations = controller?.buildDecorations() ?? []; - const inlineDecorations = - activeEditor?.internals.getSlot(AI_AUTOCOMPLETE_CONTROLLER_SLOT) == null - ? (inlineCompletion?.buildDecorations() ?? []) - : []; - return createDecorationSet([...decorations, ...inlineDecorations]); - }, - }); -} - -export function getAIController(editor: Editor): AIController | null { - return editor.internals.getSlot(AI_CONTROLLER_SLOT) ?? null; -} - -export function getInlineCompletionController( - editor: Editor, -): AIInlineCompletionController | null { - return getInlineCompletionControllerFromCore(editor); -} - -export function getAIInlineCompletionController( - editor: Editor, -): AIInlineCompletionController | null { - return getInlineCompletionController(editor); -} - -export function getAIInlineHistoryController( - editor: Editor, -): AIInlineHistoryController | null { - return editor.internals.getSlot( - AI_INLINE_HISTORY_SLOT, - ) ?? null; -} - -export function getAIReviewController(editor: Editor): AIReviewController | null { - return editor.internals.getSlot( - AI_REVIEW_CONTROLLER_SLOT, - ) ?? null; -} - -function resolveOrderedReviewItems( - reviewItems: readonly StructuralReviewItem[], - ids: readonly string[], -): StructuralReviewItem[] { - const remainingIds = new Set(ids); - const orderedReviewItems: StructuralReviewItem[] = []; - for (const reviewItem of reviewItems) { - if (!remainingIds.has(reviewItem.id)) { - continue; - } - orderedReviewItems.push(reviewItem); - remainingIds.delete(reviewItem.id); - } - return orderedReviewItems; -} - -function sortReviewItemsForRemoval( - reviewItems: readonly StructuralReviewItem[], -): StructuralReviewItem[] { - return [...reviewItems].sort(compareReviewItemRemovalOrder); -} - -function compareReviewItemRemovalOrder( - left: StructuralReviewItem, - right: StructuralReviewItem, -): number { - const maxPathLength = Math.max(left.bundlePath.length, right.bundlePath.length); - for (let index = 0; index < maxPathLength; index += 1) { - const leftPart = left.bundlePath[index] ?? -1; - const rightPart = right.bundlePath[index] ?? -1; - if (leftPart !== rightPart) { - return rightPart - leftPart; - } - } - - const leftStepIndex = left.stepIndex ?? -1; - const rightStepIndex = right.stepIndex ?? -1; - return rightStepIndex - leftStepIndex; -} - -function resolveActiveBlockId(selection: SelectionState): string | null { - if (!selection) return null; - if (selection.type === "text") return selection.focus.blockId; - if (selection.type === "block") return selection.blockIds[0] ?? null; - if (selection.type === "cell") return selection.blockId; - return null; -} - -function readModelId(model: ModelAdapter | undefined): string | undefined { - if (!model || typeof model !== "object") return undefined; - const candidate = model as ModelAdapter & { - name?: string; - modelId?: string; - }; - return candidate.modelId ?? candidate.name; -} - -function supportsStructuredIntent( - model: ModelAdapter | undefined, -): boolean { - return model?.capabilities?.structuredIntent === true; -} - -type AIStreamEventInput = - | { - type: "generation-start"; - prompt: string; - target: GenerationState["target"]; - } - | { - type: "status"; - status: AIControllerState["status"]; - } - | { - type: "text-delta"; - delta: string; - text: string; - } - | { - type: "operation"; - operation: AIRequestedOperation; - phase: "preview" | "final" | "conflict"; - text?: string; - reason?: string; - } - | { - type: "app-partial"; - data: unknown; - final: boolean; - } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - } - | { - type: "tool-output"; - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - } - | { - type: "tool-result"; - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - } - | { - type: "structured-preview"; - preview: GenerationStructuredPreviewState; - patches: readonly { - op: "add" | "remove" | "replace"; - path: string; - value?: unknown; - }[]; - } - | { - type: "generation-finish"; - status: GenerationState["status"]; - text: string; - }; - -function createAIStreamEvent( - generation: Pick, - event: AIStreamEventInput, -): AIStreamEvent { - return { - ...event, - generationId: generation.id, - sessionId: generation.sessionId, - zoneId: generation.zoneId, - blockId: generation.blockId, - timestamp: Date.now(), - }; -} - -function resolvePromptTarget( - selection: SelectionState, - target: "auto" | "selection" | "block" | "document" | undefined, -): "selection" | "block" | "document" { - if (target === "selection") { - return "selection"; - } - if (target === "block") { - return "block"; - } - if (target === "document") { - return "document"; - } - return selection?.type === "text" && !selection.isCollapsed ? "selection" : "block"; -} - -function resolveSessionTarget( - editor: Editor, - target: "auto" | "selection" | "block" | "document" | undefined, -): AISessionTarget { - if (target === "document") { - return { kind: "document" }; - } - const selection = editor.selection; - if ( - (target === "selection" || target === "auto") && - selection?.type === "text" && - !selection.isCollapsed - ) { - const range = selection.toRange(); - const selectionSnapshot = resolveSessionSelectionSnapshot(selection); - return { - kind: "selection", - selection: recreateTextSelection(editor, selectionSnapshot), - blockId: range.start.blockId, - }; - } - const blockId = - (target === "block" || target === "auto") - ? resolveActiveBlockId(selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null - : null; - return blockId ? { kind: "block", blockId } : { kind: "document" }; -} - -function resolveSessionAnchor( - selection: SelectionState | TextSelection, -): AISession["anchor"] | undefined { - if (selection?.type !== "text") { - return undefined; - } - const range = selection.toRange(); - return { - blockId: range.start.blockId, - from: range.start.offset, - to: range.end.offset, - }; -} - -function resolveSessionSelectionSnapshot( - selection: TextSelection, -): AISessionSelectionSnapshot { - return { - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - blockRange: [...selection.blockRange], - isMultiBlock: selection.isMultiBlock, - }; -} - -function resolveContextualPromptAnchor( - target: AISessionTarget, -): NonNullable["anchor"] { - if (target.kind === "selection") { - const range = target.selection.toRange(); - return { - kind: "text-range", - selectionSnapshot: resolveSessionSelectionSnapshot(target.selection), - focusBlockId: range.start.blockId, - status: "valid", - lastResolvedRect: null, - }; - } - if (target.kind === "block") { - return { - kind: "block", - focusBlockId: target.blockId, - status: "valid", - lastResolvedRect: null, - }; - } - return { - kind: "document", - focusBlockId: null, - status: "valid", - lastResolvedRect: null, - }; -} - -function resolveContextualPromptState( - target: AISessionTarget, -): NonNullable { - return { - anchor: resolveContextualPromptAnchor(target), - composer: { - draftPrompt: "", - isOpen: true, - isSubmitting: false, - canSubmitFollowUp: true, - openReason: "user", - }, - }; -} - -function createInlineHistorySnapshot( - editor: Editor, - sessions: readonly AISession[], - activeSessionId: string | null, - documentVersion: number, - options?: { - kind?: AIInlineHistorySnapshot["kind"]; - }, -): AIInlineHistorySnapshot { - return { - id: crypto.randomUUID(), - sessionId: activeSessionId, - sessions: cloneInlineHistorySessions(editor, sessions), - activeSessionId, - documentVersion, - kind: options?.kind ?? "document-coupled", - }; -} - -function cloneSessionTarget( - editor: Editor, - target: AISessionTarget, -): AISessionTarget { - if (target.kind !== "selection") { - return { ...target }; - } - return { - kind: "selection", - blockId: target.blockId, - selection: recreateTextSelection( - editor, - resolveSessionSelectionSnapshot(target.selection), - ), - }; -} - -function cloneInlineHistorySessions( - editor: Editor, - sessions: readonly AISession[], -): AISession[] { - return sessions.map((session) => ({ - ...session, - target: cloneSessionTarget(editor, session.target), - contextualPrompt: session.contextualPrompt - ? { - ...session.contextualPrompt, - anchor: { - ...session.contextualPrompt.anchor, - selectionSnapshot: session.contextualPrompt.anchor.selectionSnapshot - ? { - ...session.contextualPrompt.anchor.selectionSnapshot, - anchor: { - ...session.contextualPrompt.anchor.selectionSnapshot.anchor, - }, - focus: { - ...session.contextualPrompt.anchor.selectionSnapshot.focus, - }, - blockRange: [ - ...session.contextualPrompt.anchor.selectionSnapshot.blockRange, - ], - } - : undefined, - }, - composer: { - ...session.contextualPrompt.composer, - }, - } - : undefined, - turns: session.turns.map((turn) => ({ - ...turn, - suggestionIds: [...turn.suggestionIds], - reviewItemIds: [...turn.reviewItemIds], - anchor: turn.anchor ? { ...turn.anchor } : undefined, - selection: turn.selection - ? { - ...turn.selection, - anchor: { ...turn.selection.anchor }, - focus: { ...turn.selection.focus }, - blockRange: [...turn.selection.blockRange], - } - : undefined, - })), - promptHistory: session.promptHistory.map((prompt) => ({ ...prompt })), - generationIds: [...session.generationIds], - pendingSuggestionIds: [...session.pendingSuggestionIds], - pendingReviewItemIds: [...session.pendingReviewItemIds], - metrics: { - ...session.metrics, - fastApply: { ...session.metrics.fastApply }, - }, - anchor: session.anchor ? { ...session.anchor } : undefined, - })); -} - -function recreateTextSelection( - editor: Editor, - snapshot: AISessionSelectionSnapshot, -): TextSelection { - const blockRange = resolveSelectionSnapshotBlockRange(editor, snapshot); - const isCollapsed = - snapshot.anchor.blockId === snapshot.focus.blockId && - snapshot.anchor.offset === snapshot.focus.offset; - const documentRange = { - start: resolveSelectionSnapshotRangeStart(snapshot, blockRange), - end: resolveSelectionSnapshotRangeEnd(snapshot, blockRange), - get isMultiBlock() { - return blockRange.length > 1; - }, - get blockRange() { - return [...blockRange]; - }, - contains(point: { blockId: string; offset: number }): boolean { - if (!blockRange.includes(point.blockId)) { - return false; - } - const isSingleBlock = blockRange.length === 1; - if (isSingleBlock) { - return ( - point.offset >= this.start.offset && point.offset <= this.end.offset - ); - } - if (point.blockId === this.start.blockId) { - return point.offset >= this.start.offset; - } - if (point.blockId === this.end.blockId) { - return point.offset <= this.end.offset; - } - return true; - }, - overlaps(other: { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - contains: (point: { blockId: string; offset: number }) => boolean; - }): boolean { - return this.contains(other.start) || this.contains(other.end) || other.contains(this.start); - }, - equals(other: { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - }): boolean { - return ( - this.start.blockId === other.start.blockId && - this.start.offset === other.start.offset && - this.end.blockId === other.end.blockId && - this.end.offset === other.end.offset - ); - }, - toTextSelection() { - return recreateTextSelection(editor, snapshot); - }, - }; - return { - type: "text", - anchor: { ...snapshot.anchor }, - focus: { ...snapshot.focus }, - get isCollapsed() { - return isCollapsed; - }, - get isMultiBlock() { - return blockRange.length > 1; - }, - get blockRange() { - return [...blockRange]; - }, - toRange() { - return documentRange; - }, - }; -} - -function resolveSelectionSnapshotBlockRange( - editor: Editor, - snapshot: AISessionSelectionSnapshot, -): string[] { - if (snapshot.blockRange.length > 0) { - return [...snapshot.blockRange]; - } - const blockOrder = editor.documentState.blockOrder; - const anchorIndex = blockOrder.indexOf(snapshot.anchor.blockId); - const focusIndex = blockOrder.indexOf(snapshot.focus.blockId); - if (anchorIndex === -1 || focusIndex === -1) { - return [snapshot.anchor.blockId]; - } - const startIndex = Math.min(anchorIndex, focusIndex); - const endIndex = Math.max(anchorIndex, focusIndex); - return blockOrder.slice(startIndex, endIndex + 1); -} - -function resolveSelectionSnapshotRangeStart( - snapshot: AISessionSelectionSnapshot, - blockRange: readonly string[], -): { blockId: string; offset: number } { - if (blockRange.length <= 1) { - return { - blockId: snapshot.anchor.blockId, - offset: Math.min(snapshot.anchor.offset, snapshot.focus.offset), - }; - } - const firstBlockId = blockRange[0] ?? snapshot.anchor.blockId; - return snapshot.anchor.blockId === firstBlockId - ? { ...snapshot.anchor } - : { ...snapshot.focus }; -} - -function resolveSelectionSnapshotRangeEnd( - snapshot: AISessionSelectionSnapshot, - blockRange: readonly string[], -): { blockId: string; offset: number } { - if (blockRange.length <= 1) { - return { - blockId: snapshot.anchor.blockId, - offset: Math.max(snapshot.anchor.offset, snapshot.focus.offset), - }; - } - const lastBlockId = blockRange[blockRange.length - 1] ?? snapshot.focus.blockId; - return snapshot.anchor.blockId === lastBlockId - ? { ...snapshot.anchor } - : { ...snapshot.focus }; -} - -function resolveRequestedOperationForSession( - editor: Editor, - session: AISession, - prompt: string, - options: AICommandExecutionOptions | undefined, - documentVersion: number, -): AIRequestedOperation { - const explicitTarget = options?.target; - const promptIntent = classifyPromptIntent(prompt); - const capturedSelection = resolveSessionSelectionTarget(editor, session); - const liveSelection = - session.surface === "inline-edit" - ? capturedSelection - : editor.selection?.type === "text" && !editor.selection.isCollapsed - ? editor.selection - : capturedSelection; - const activeBlockId = - options?.blockId ?? - resolveSessionBlockId(editor, session) ?? - resolveActiveBlockId(editor.selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null; - const documentActiveBlockId = - options?.blockId ?? - resolveActiveBlockId(editor.selection) ?? - session.anchor?.blockId ?? - null; - const resolvedEditProposal = resolveResolvedEditProposal( - editor, - session, - prompt, - promptIntent, - explicitTarget, - liveSelection, - "markdown", - ); - const clearDocument = - session.target.kind === "document" && isClearDocumentPrompt(prompt); - const documentBlockIds = editor.documentState.blockOrder.filter( - (blockId) => editor.getBlock(blockId) != null, - ); - const documentTransformPlan = clearDocument - ? { - blockIds: documentBlockIds, - placement: "replace-blocks" as const, - transform: "remove" as const, - } - : undefined; - - if (resolvedEditProposal) { - return createRewriteSelectionOperationFromResolvedTarget( - editor, - resolvedEditProposal.target, - resolvedEditProposal.promptIntent, - documentVersion, - ); - } - if (promptIntent === "continue" && activeBlockId) { - if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { - return createDocumentTransformOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - { - blockIds: [activeBlockId], - placement: "append-after-block", - transform: "write", - }, - ); - } - return createContinueBlockOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - ); - } - if ( - activeBlockId && - (promptIntent === "rewrite" || - (promptIntent === "local-edit" && - (editor.getBlock(activeBlockId)?.textContent().length ?? 0) > 0) || - explicitTarget === "block") - ) { - if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { - return createDocumentTransformOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - { - blockIds: [activeBlockId], - placement: "replace-blocks", - transform: "rewrite", - }, - ); - } - return createRewriteBlockOperation( - editor, - activeBlockId, - promptIntent, - documentVersion, - ); - } - if (explicitTarget === "document") { - return createDocumentTransformOperation( - editor, - documentActiveBlockId, - promptIntent, - documentVersion, - documentTransformPlan, - ); - } - return createDocumentTransformOperation( - editor, - session.target.kind === "document" ? documentActiveBlockId : activeBlockId, - promptIntent, - documentVersion, - documentTransformPlan, - ); -} - -function resolveLocalOperationContentFormat( - editor: Editor, - operation: AIRequestedOperation, - defaultBlockFormat: AIContentFormat, -): AIContentFormat { - if (operation.kind === "rewrite-selection") { - return operation.target.kind === "scoped-range" - ? operation.target.contentFormat - : "text"; - } - if (operation.kind === "document-transform") { - return defaultBlockFormat; - } - if (operation.kind !== "rewrite-block") { - return "text"; - } - const blockId = operation.target.kind === "block" ? operation.target.blockId : null; - if (blockId && resolveFullBlockTextSelection(editor, blockId)) { - return "text"; - } - return defaultBlockFormat; -} - -function canUseLocalBlockTextOperation(editor: Editor, blockId: string): boolean { - const block = editor.getBlock(blockId); - if (!block) { - return false; - } - const schema = editor.schema.resolve(block.type); - if (!schema || !usesInlineTextSelection(schema)) { - return false; - } - return resolveFullBlockTextSelection(editor, blockId) != null; -} - -function canReuseBottomChatSessionOperation( - previousOperation: AIRequestedOperation, - nextOperation: AIRequestedOperation, -): boolean { - const previousResolvedTarget = - resolveResolvedEditTargetFromRequestedOperation(previousOperation); - const nextResolvedTarget = - resolveResolvedEditTargetFromRequestedOperation(nextOperation); - if (previousResolvedTarget && nextResolvedTarget) { - return areResolvedEditTargetsEqual(previousResolvedTarget, nextResolvedTarget); - } - if (previousOperation.kind !== nextOperation.kind) { - return false; - } - if (previousOperation.target.kind !== nextOperation.target.kind) { - return false; - } - if ( - previousOperation.target.kind === "selection" || - previousOperation.target.kind === "scoped-range" - ) { - if ( - nextOperation.target.kind !== "selection" && - nextOperation.target.kind !== "scoped-range" - ) { - return false; - } - return ( - previousOperation.provenance?.selectionSignature === - nextOperation.provenance?.selectionSignature && - previousOperation.target.sourceText === nextOperation.target.sourceText - ); - } - if (previousOperation.target.kind === "block") { - if (nextOperation.target.kind !== "block") { - return false; - } - return ( - previousOperation.target.blockId === nextOperation.target.blockId && - previousOperation.provenance?.blockRevision === - nextOperation.provenance?.blockRevision - ); - } - if (nextOperation.target.kind !== "document") { - return false; - } - return ( - previousOperation.target.activeBlockId === nextOperation.target.activeBlockId && - areStructuredValuesEqual( - previousOperation.target.blockIds ?? [], - nextOperation.target.blockIds ?? [], - ) && - (previousOperation.target.placement ?? null) === - (nextOperation.target.placement ?? null) && - (previousOperation.target.transform ?? null) === - (nextOperation.target.transform ?? null) - ); -} - -function resolveResolvedEditTargetFromRequestedOperation( - operation: AIRequestedOperation, -): ResolvedEditTarget | null { - if ( - operation.target.kind !== "selection" && - operation.target.kind !== "scoped-range" - ) { - return null; - } - return operation.target; -} - -function areResolvedEditTargetsEqual( - previousTarget: ResolvedEditTarget, - nextTarget: ResolvedEditTarget, -): boolean { - if (previousTarget.kind !== nextTarget.kind) { - return false; - } - if ( - previousTarget.blockId !== nextTarget.blockId || - previousTarget.sourceText !== nextTarget.sourceText || - previousTarget.anchor.blockId !== nextTarget.anchor.blockId || - previousTarget.anchor.offset !== nextTarget.anchor.offset || - previousTarget.focus.blockId !== nextTarget.focus.blockId || - previousTarget.focus.offset !== nextTarget.focus.offset - ) { - return false; - } - if (previousTarget.kind === "scoped-range" && nextTarget.kind === "scoped-range") { - return ( - previousTarget.scope === nextTarget.scope && - previousTarget.contentFormat === nextTarget.contentFormat && - areStructuredValuesEqual(previousTarget.blockIds, nextTarget.blockIds) - ); - } - return true; -} - -function isClearDocumentPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - return ( - /\b(remove|delete|clear|erase|wipe)\b/.test(normalizedPrompt) && - /\b(all|entire|whole|everything)\b/.test(normalizedPrompt) && - /\b(document|content|contents|text|story|page)\b/.test(normalizedPrompt) - ); -} - -function isWholeDocumentRewritePrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - return ( - /\b(rewrite|redo|revise|rework|replace)\s+(?:the|this|my)?\s*(?:entire|whole|full|all)?\s*(?:document|content|contents|text|story|page)\b/.test( - normalizedPrompt, - ) || /\bmake (?:it|this) about\b/.test(normalizedPrompt) - ); -} - -function isDocumentResetPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - return /\b(start(?:ing)?\s+(?:over|again|from scratch)|begin\s+again|from scratch|restart)\b/.test( - normalizedPrompt, - ); -} - -function isDocumentFollowUpEditPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim().toLowerCase(); - if (/\b(continue|append|add|insert|another|more|next)\b/.test(normalizedPrompt)) { - return false; - } - return ( - /\b(change|update|adjust|edit|fix|improve|polish|revise|rework|rename|retitle|make)\b/.test( - normalizedPrompt, - ) && - (/\b(title|heading|story|document|content|contents|text|tone|voice|ending|opening|intro|introduction|theme)\b/.test( - normalizedPrompt, - ) || /\bmake (?:it|this)\b/.test(normalizedPrompt)) - ); -} - -function buildSessionExecutionPrompt( - session: AISession | null, - prompt: string, -): string { - if (!session) { - return prompt; - } - const previousPrompts = session.promptHistory - .map((item) => item.prompt.trim()) - .filter((item) => item.length > 0) - .slice(-4); - if (previousPrompts.length === 0) { - return prompt; - } - const historyLines = previousPrompts.map( - (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, - ); - const intro = - session.surface === "inline-edit" - ? "You are continuing an existing inline editor edit session." - : "You are continuing an existing editor chat session."; - const applyInstruction = - session.surface === "inline-edit" - ? "Apply the latest request to the current selected document state." - : "Apply the latest request to the current document state."; - return [ - intro, - "Earlier user requests in this same session:", - ...historyLines, - "", - applyInstruction, - "Latest request:", - prompt, - ].join("\n"); -} - -function createRewriteSelectionOperation( - editor: Editor, - selection: TextSelection, - promptIntent: string, - documentVersion: number, - options?: { - sourceText?: string; - }, -): AIRequestedOperation { - const range = selection.toRange(); - return { - kind: "rewrite-selection", - applyPolicy: "selection-replace", - promptIntent, - target: { - kind: "selection", - blockId: range.start.blockId, - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - sourceText: options?.sourceText ?? resolveSelectionText(editor, selection), - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(range.start.blockId), - selectionSignature: createSelectionSignature(selection), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createRewriteSelectionOperationFromResolvedTarget( - editor: Editor, - target: ResolvedEditTarget, - promptIntent: string, - documentVersion: number, -): AIRequestedOperation { - const selection = recreateTextSelection(editor, { - anchor: target.anchor, - focus: target.focus, - blockRange: resolveSelectionTargetBlockIds(editor, target), - isMultiBlock: - resolveSelectionTargetBlockIds(editor, target).length > 1 || - target.anchor.blockId !== target.focus.blockId, - }); - if (target.kind === "selection") { - return createRewriteSelectionOperation( - editor, - selection, - promptIntent, - documentVersion, - { - sourceText: target.sourceText, - }, - ); - } - return { - kind: "rewrite-selection", - applyPolicy: "selection-replace", - promptIntent, - target: { - kind: "scoped-range", - blockId: target.blockId, - anchor: { ...target.anchor }, - focus: { ...target.focus }, - sourceText: target.sourceText, - blockIds: [...target.blockIds], - contentFormat: target.contentFormat, - scope: target.scope, - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(target.blockId ?? selection.anchor.blockId), - selectionSignature: createSelectionSignature(selection), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createRewriteBlockOperation( - editor: Editor, - blockId: string, - promptIntent: string, - documentVersion: number, -): AIRequestedOperation { - const block = editor.getBlock(blockId); - return { - kind: "rewrite-block", - applyPolicy: "block-replace", - promptIntent, - target: { - kind: "block", - blockId, - blockType: block?.type ?? null, - sourceText: block?.textContent() ?? "", - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(blockId), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createContinueBlockOperation( - editor: Editor, - blockId: string, - promptIntent: string, - documentVersion: number, -): AIRequestedOperation { - const block = editor.getBlock(blockId); - return { - kind: "continue-block", - applyPolicy: "block-continue", - promptIntent, - target: { - kind: "block", - blockId, - blockType: block?.type ?? null, - sourceText: block?.textContent() ?? "", - insertionOffset: resolveContinueInsertionOffset(editor, blockId), - }, - provenance: { - documentVersion, - blockRevision: editor.getBlockRevision(blockId), - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function createDocumentTransformOperation( - editor: Editor, - activeBlockId: string | null, - promptIntent: string, - documentVersion: number, - options?: { - blockIds?: readonly string[]; - placement?: "append-after-block" | "replace-empty-block" | "replace-blocks"; - transform?: "write" | "rewrite" | "remove"; - }, -): AIRequestedOperation { - return { - kind: "document-transform", - applyPolicy: "document-review", - promptIntent, - target: { - kind: "document", - activeBlockId, - blockIds: options?.blockIds, - placement: options?.placement, - transform: options?.transform, - }, - provenance: { - documentVersion, - syncedGeneration: editor.documentState.generation, - }, - }; -} - -function resolvePreviousGeneratedBlockIds(session: AISession): string[] { - const completedTurns = session.turns.filter( - (turn) => turn.status === "complete" || turn.status === "accepted", - ); - const lastTurnWithBlocks = completedTurns - .slice() - .reverse() - .find((turn) => turn.generatedBlockIds.length > 0); - return lastTurnWithBlocks?.generatedBlockIds ?? []; -} - -function shouldReplacePreviousGeneratedBlocks( - session: AISession, - prompt: string, -): boolean { - return ( - session.surface === "bottom-chat" && - session.target.kind === "document" && - (classifyPromptIntent(prompt) === "rewrite" || - isDocumentResetPrompt(prompt) || - isDocumentFollowUpEditPrompt(prompt)) - ); -} - -function resolveReplacementDeleteBlockIds( - editor: Editor, - blockId: string, - replaceBlockIds?: readonly string[], -): string[] { - const requestedIds = - replaceBlockIds && replaceBlockIds.length > 0 ? replaceBlockIds : [blockId]; - const deleteBlockIds = requestedIds.filter( - (candidateBlockId, index, allBlockIds) => - allBlockIds.indexOf(candidateBlockId) === index && - editor.getBlock(candidateBlockId) != null, - ); - return deleteBlockIds.length > 0 ? deleteBlockIds : [blockId]; -} - -function createResolvedSelectionEditTarget( - editor: Editor, - selection: TextSelection, -): ResolvedEditTarget { - const range = selection.toRange(); - return { - kind: "selection", - blockId: range.start.blockId, - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - sourceText: resolveSelectionText(editor, selection), - }; -} - -function createResolvedScopedEditTarget( - editor: Editor, - selection: TextSelection, - scope: ModelOperationScopedRangeTarget["scope"], - contentFormat: AIContentFormat, -): ResolvedEditTarget { - const range = selection.toRange(); - return { - kind: "scoped-range", - scope, - blockId: range.start.blockId, - anchor: { ...selection.anchor }, - focus: { ...selection.focus }, - blockIds: [...range.blockRange], - sourceText: resolveSelectionText(editor, selection), - contentFormat, - }; -} - -function createResolvedEditProposal( - promptIntent: string, - target: ResolvedEditTarget, -): ResolvedEditProposal { - return { - promptIntent, - target, - }; -} - -function resolveResolvedEditProposal( - editor: Editor, - session: AISession, - prompt: string, - promptIntent: string, - explicitTarget: AICommandExecutionOptions["target"] | undefined, - liveSelection: TextSelection | null, - defaultBlockFormat: AIContentFormat, -): ResolvedEditProposal | null { - if (liveSelection && explicitTarget === "selection") { - return createResolvedEditProposal( - promptIntent, - createResolvedSelectionEditTarget(editor, liveSelection), - ); - } - - const selectionScopedSession = session.target.kind === "selection"; - if ( - liveSelection && - (session.surface === "inline-edit" || - (selectionScopedSession && - (promptIntent === "rewrite" || promptIntent === "local-edit"))) - ) { - return createResolvedEditProposal( - promptIntent, - createResolvedSelectionEditTarget(editor, liveSelection), - ); - } - - if (session.target.kind !== "document" && explicitTarget !== "document") { - return null; - } - if ( - promptIntent === "continue" || - promptIntent === "review" || - promptIntent === "search" || - promptIntent === "structural" - ) { - return null; - } - - const titleSelection = resolveDocumentTitleSelection(editor, prompt); - if (titleSelection) { - return createResolvedEditProposal( - promptIntent, - createResolvedScopedEditTarget( - editor, - titleSelection, - "heading", - defaultBlockFormat, - ), - ); - } - - const paragraphSelection = resolveDocumentParagraphSelection(editor, prompt); - if (paragraphSelection) { - return createResolvedEditProposal( - promptIntent, - createResolvedScopedEditTarget( - editor, - paragraphSelection, - "paragraph", - defaultBlockFormat, - ), - ); - } - - const documentBlockIds = editor.documentState.blockOrder.filter( - (blockId) => editor.getBlock(blockId) != null, - ); - const documentHasMeaningfulContent = documentBlockIds.some((blockId) => { - const block = editor.getBlock(blockId); - return (block?.textContent().trim().length ?? 0) > 0; - }); - const shouldRewriteDocumentScope = - !documentHasMeaningfulContent || - promptIntent === "rewrite" || - isClearDocumentPrompt(prompt) || - isWholeDocumentRewritePrompt(prompt) || - isDocumentResetPrompt(prompt) || - isDocumentFollowUpEditPrompt(prompt); - if (!shouldRewriteDocumentScope) { - return null; - } - - const documentSelection = resolveDocumentBlockRangeSelection( - editor, - documentBlockIds, - ); - if (!documentSelection) { - return null; - } - return createResolvedEditProposal( - promptIntent, - createResolvedScopedEditTarget( - editor, - documentSelection, - "document", - defaultBlockFormat, - ), - ); -} - -function resolveSelectionForRequestedOperation( - editor: Editor, - operation: AIRequestedOperation, -): TextSelection | null { - if ( - operation.target.kind !== "selection" && - operation.target.kind !== "scoped-range" - ) { - return null; - } - return recreateTextSelection(editor, { - anchor: operation.target.anchor, - focus: operation.target.focus, - blockRange: resolveSelectionTargetBlockIds(editor, operation.target), - isMultiBlock: - resolveSelectionTargetBlockIds(editor, operation.target).length > 1 || - operation.target.anchor.blockId !== operation.target.focus.blockId, - }); -} - -function resolveFullBlockTextSelection( - editor: Editor, - blockId: string, -): TextSelection | null { - const block = editor.getBlock(blockId); - if (!block) { - return null; - } - return recreateTextSelection(editor, { - anchor: { blockId, offset: 0 }, - focus: { blockId, offset: block.textContent().length }, - blockRange: [blockId], - isMultiBlock: false, - }); -} - -function resolveDocumentBlockRangeSelection( - editor: Editor, - blockIds: readonly string[], -): TextSelection | null { - const resolvedBlockIds = blockIds.filter( - (blockId, index, allBlockIds) => - allBlockIds.indexOf(blockId) === index && editor.getBlock(blockId) != null, - ); - const firstBlockId = resolvedBlockIds[0]; - const lastBlockId = resolvedBlockIds[resolvedBlockIds.length - 1]; - if (!firstBlockId || !lastBlockId) { - return null; - } - const lastBlock = editor.getBlock(lastBlockId); - return recreateTextSelection(editor, { - anchor: { blockId: firstBlockId, offset: 0 }, - focus: { - blockId: lastBlockId, - offset: lastBlock?.textContent().length ?? 0, - }, - blockRange: resolvedBlockIds, - isMultiBlock: resolvedBlockIds.length > 1, - }); -} - -function resolveDocumentTitleSelection( - editor: Editor, - prompt: string, -): TextSelection | null { - if (!/\b(title|heading)\b/i.test(prompt)) { - return null; - } - const headingBlockId = - editor.documentState.blockOrder.find((blockId) => { - const block = editor.getBlock(blockId); - return block?.type === "heading" || block?.type.startsWith("heading-"); - }) ?? - editor.firstBlock()?.id ?? - null; - return headingBlockId - ? resolveDocumentBlockRangeSelection(editor, [headingBlockId]) - : null; -} - -function resolveDocumentParagraphSelection( - editor: Editor, - prompt: string, -): TextSelection | null { - const paragraphIndex = parseParagraphReference(prompt); - if (paragraphIndex == null) { - return null; - } - const paragraphBlockIds = editor.documentState.blockOrder.filter((blockId) => { - const block = editor.getBlock(blockId); - if (!block) { - return false; - } - return ( - block.type === "paragraph" || - (block.textContent().trim().length > 0 && - block.type !== "heading" && - !block.type.startsWith("heading-")) - ); - }); - const targetParagraphBlockId = paragraphBlockIds[paragraphIndex - 1] ?? null; - return targetParagraphBlockId - ? resolveDocumentBlockRangeSelection(editor, [targetParagraphBlockId]) - : null; -} - -function parseParagraphReference(prompt: string): number | null { - const match = prompt.match( - /\b(?:(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)|(\d+)(?:st|nd|rd|th))\s+paragraph\b/i, - ); - if (!match) { - return null; - } - const wordOrdinal = match[1]?.toLowerCase(); - if (wordOrdinal) { - return resolveWordOrdinal(wordOrdinal); - } - const numericOrdinal = Number.parseInt(match[2] ?? "", 10); - return Number.isFinite(numericOrdinal) && numericOrdinal > 0 - ? numericOrdinal - : null; -} - -function resolveWordOrdinal(word: string): number | null { - switch (word) { - case "first": - return 1; - case "second": - return 2; - case "third": - return 3; - case "fourth": - return 4; - case "fifth": - return 5; - case "sixth": - return 6; - case "seventh": - return 7; - case "eighth": - return 8; - case "ninth": - return 9; - case "tenth": - return 10; - default: - return null; - } -} - -function resolveBlockIdForRequestedOperation( - operation: AIRequestedOperation, -): string | null { - if (operation.target.kind === "block") { - return operation.target.blockId; - } - if ( - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" - ) { - return operation.target.blockId; - } - return operation.target.activeBlockId; -} - -function resolveRequestedOperationConflict( - editor: Editor, - operation: AIRequestedOperation, - currentSelectionSignature: string | null, -): string | null { - if ( - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" - ) { - const selection = resolveSelectionForRequestedOperation(editor, operation); - if (!selection) { - return "The selected range no longer exists."; - } - if (isScopedSelectionTarget(operation.target)) { - if ( - renderSelectionTargetBlockText(editor, operation.target) !== - operation.target.sourceText - ) { - return "The selected text changed before the rewrite completed."; - } - return null; - } - if ( - operation.provenance?.selectionSignature != null && - operation.provenance.selectionSignature !== currentSelectionSignature - ) { - return "The selected range changed before the rewrite completed."; - } - if (resolveSelectionText(editor, selection) !== operation.target.sourceText) { - return "The selected text changed before the rewrite completed."; - } - return null; - } - if (operation.target.kind === "block") { - const block = editor.getBlock(operation.target.blockId); - if (!block) { - return "The target block no longer exists."; - } - if ( - operation.provenance?.blockRevision != null && - editor.getBlockRevision(operation.target.blockId) !== - operation.provenance.blockRevision - ) { - return "The target block changed before the operation completed."; - } - return null; - } - if ( - operation.provenance?.syncedGeneration != null && - editor.documentState.generation !== operation.provenance.syncedGeneration - ) { - return "The document changed before the operation completed."; - } - return null; -} - -function resolveContinueInsertionOffset(editor: Editor, blockId: string): number { - const selection = editor.selection; - if ( - selection?.type === "text" && - selection.isCollapsed && - selection.anchor.blockId === blockId - ) { - return selection.anchor.offset; - } - return resolveBlockInsertionOffset(editor, blockId); -} - -function createSelectionSignature(selection: TextSelection): string { - return [ - "text", - selection.anchor.blockId, - selection.anchor.offset, - selection.focus.blockId, - selection.focus.offset, - String(selection.isCollapsed), - ].join(":"); -} - -function resolveSessionSelectionTarget( - editor: Editor, - session: AISession, -): TextSelection | null { - const anchorSelection = session.contextualPrompt?.anchor.selectionSnapshot; - if (session.target.kind !== "selection" && !anchorSelection) { - return null; - } - const activeTurnSelection = session.activeTurnId - ? session.turns.find((turn) => turn.id === session.activeTurnId)?.selection - : session.turns[session.turns.length - 1]?.selection; - if (activeTurnSelection) { - const restoredSelection = recreateTextSelection(editor, activeTurnSelection); - if (!restoredSelection.isCollapsed) { - return restoredSelection; - } - } - const selection = editor.selection; - if ( - selection?.type === "text" && - !selection.isCollapsed && - selectionMatchesSnapshot( - selection, - session.target.kind === "selection" - ? resolveSessionSelectionSnapshot(session.target.selection) - : (anchorSelection ?? null), - ) - ) { - return selection; - } - if (anchorSelection) { - const restoredSelection = recreateTextSelection(editor, anchorSelection); - if (!restoredSelection.isCollapsed) { - return restoredSelection; - } - } - if (session.target.kind === "selection" && !session.target.selection.isCollapsed) { - return session.target.selection; - } - return null; -} - -function resolveLiveInlineSelectionTarget( - editor: Editor, -): Extract | null { - const selection = editor.selection; - if (selection?.type !== "text" || selection.isCollapsed) { - return null; - } - const target = resolveSessionTarget(editor, "selection"); - return target.kind === "selection" ? target : null; -} - -function resolvePendingInlineSelectionTarget( - editor: Editor, - operation: AIRequestedOperation | undefined, - suggestionIds: readonly string[], -): Extract | null { - if ( - operation?.kind !== "rewrite-selection" || - operation.target.kind !== "selection" || - operation.target.anchor.blockId !== operation.target.focus.blockId - ) { - return null; - } - const textSuggestions = readAllSuggestions(editor).filter( - (suggestion): suggestion is PersistentTextSuggestion => - suggestion.kind === "text" && - (suggestion.action === "insert" || suggestion.action === "delete") && - suggestionIds.includes(suggestion.id), - ); - if (textSuggestions.length === 0) { - return null; - } - const blockId = operation.target.anchor.blockId; - const startOffset = Math.min( - operation.target.anchor.offset, - operation.target.focus.offset, - ); - const previewSpanLength = textSuggestions.reduce( - (totalLength, suggestion) => totalLength + suggestion.length, - 0, - ); - const endOffset = startOffset + previewSpanLength; - if (endOffset <= startOffset) { - return null; - } - return { - kind: "selection", - blockId, - selection: recreateTextSelection(editor, { - anchor: { blockId, offset: startOffset }, - focus: { blockId, offset: endOffset }, - blockRange: [blockId], - isMultiBlock: false, - }), - }; -} - -function resolveAcceptedInlineSelectionTarget( - editor: Editor, - operation: AIRequestedOperation | undefined, - suggestionIds: readonly string[], -): Extract | null { - if ( - operation?.kind !== "rewrite-selection" || - operation.target.kind !== "selection" || - operation.target.anchor.blockId !== operation.target.focus.blockId - ) { - return null; - } - const insertSuggestions = readAllSuggestions(editor).filter( - (suggestion): suggestion is PersistentTextSuggestion => - suggestion.kind === "text" && - suggestion.action === "insert" && - suggestionIds.includes(suggestion.id), - ); - if (insertSuggestions.length === 0) { - return null; - } - const blockId = operation.target.anchor.blockId; - const startOffset = Math.min( - operation.target.anchor.offset, - operation.target.focus.offset, - ); - const insertedLength = insertSuggestions.reduce( - (totalLength, suggestion) => totalLength + suggestion.length, - 0, - ); - const endOffset = startOffset + insertedLength; - if (endOffset <= startOffset) { - return null; - } - return { - kind: "selection", - blockId, - selection: recreateTextSelection(editor, { - anchor: { blockId, offset: startOffset }, - focus: { blockId, offset: endOffset }, - blockRange: [blockId], - isMultiBlock: false, - }), - }; -} - -function shouldCloseInlineSessionPrompt(session: AISession): boolean { - return session.surface === "inline-edit" && session.contextualPrompt != null; -} - -function closeInlineSessionPrompt( - session: AISession, -): AISession["contextualPrompt"] | undefined { - if (!shouldCloseInlineSessionPrompt(session) || !session.contextualPrompt) { - return session.contextualPrompt; - } - - return { - ...session.contextualPrompt, - composer: { - ...session.contextualPrompt.composer, - isOpen: false, - isSubmitting: false, - }, - }; -} - -function createDefaultSessionFastApplyMetrics(): AISessionMetrics["fastApply"] { - return { - attemptCount: 0, - nativeFastApplyCount: 0, - scopedReplacementCount: 0, - plainMarkdownCount: 0, - failedCount: 0, - }; -} - -function accumulateSessionFastApplyMetrics( - current: AISessionMetrics["fastApply"] | undefined, - fastApply: FastApplyDebugState | undefined, -): AISessionMetrics["fastApply"] { - const next = { - ...(current ?? createDefaultSessionFastApplyMetrics()), - }; - if (!fastApply?.attempted) { - return next; - } - next.attemptCount += 1; - switch (fastApply.executionPath) { - case "native-fast-apply": - next.nativeFastApplyCount += 1; - return next; - case "scoped-replacement": - next.scopedReplacementCount += 1; - return next; - case "plain-markdown": - next.plainMarkdownCount += 1; - return next; - default: - next.failedCount += 1; - return next; - } -} - -function selectionMatchesSnapshot( - selection: TextSelection, - snapshot: AISessionSelectionSnapshot | null, -): boolean { - if (!snapshot) { - return false; - } - - return ( - selection.anchor.blockId === snapshot.anchor.blockId && - selection.anchor.offset === snapshot.anchor.offset && - selection.focus.blockId === snapshot.focus.blockId && - selection.focus.offset === snapshot.focus.offset && - selection.isMultiBlock === snapshot.isMultiBlock && - selection.blockRange.length === snapshot.blockRange.length && - selection.blockRange.every((blockId, index) => blockId === snapshot.blockRange[index]) - ); -} - -function resolveSessionSelectionSnapshots( - session: AISession, -): readonly AISessionSelectionSnapshot[] { - const snapshots: AISessionSelectionSnapshot[] = []; - const activeTurn = - session.activeTurnId != null - ? session.turns.find((turn) => turn.id === session.activeTurnId) ?? null - : session.turns[session.turns.length - 1] ?? null; - if (activeTurn?.selection) { - snapshots.push(activeTurn.selection); - } - if (session.contextualPrompt?.anchor.selectionSnapshot) { - snapshots.push(session.contextualPrompt.anchor.selectionSnapshot); - } - if (session.target.kind === "selection") { - snapshots.push(resolveSessionSelectionSnapshot(session.target.selection)); - } - return snapshots; -} - -function sessionTargetMatches( - session: AISession, - target: AISessionTarget, -): boolean { - if (session.target.kind !== target.kind) { - return false; - } - if (target.kind !== "selection") { - return areStructuredValuesEqual(session.target, target); - } - return sessionSelectionMatches(session, target.selection); -} - -function sessionSelectionMatches( - session: AISession, - selection: TextSelection, -): boolean { - return resolveSessionSelectionSnapshots(session).some((snapshot) => - selectionMatchesSnapshot(selection, snapshot), - ); -} - -function resolveSessionBlockId( +export function getAIReviewController( editor: Editor, - session: AISession, -): string | null { - if (session.target.kind === "block") { - return session.target.blockId; - } - if (session.target.kind === "selection") { - return session.target.blockId; - } - return ( - resolveActiveBlockId(editor.selection) ?? - editor.lastBlock()?.id ?? - editor.firstBlock()?.id ?? - null - ); -} - -function resolveBlockInsertionOffset(editor: Editor, blockId: string): number { - const selection = editor.selection; - const block = editor.getBlock(blockId); - const fallbackOffset = - block && isVisuallyEmptyInlineText(block.textContent()) - ? 0 - : block?.textContent().length ?? 0; - if (selection?.type !== "text") { - return fallbackOffset; - } - const range = selection.toRange(); - if (selection.isCollapsed) { - return selection.anchor.blockId === blockId - ? selection.anchor.offset - : fallbackOffset; - } - if (range.start.blockId === blockId && range.end.blockId === blockId) { - return range.end.offset; - } - if (range.end.blockId === blockId) { - return range.end.offset; - } - if (range.start.blockId === blockId) { - return range.start.offset; - } - return fallbackOffset; -} - -function appendUniqueString(values: readonly string[], value: string): string[] { - return values.includes(value) ? [...values] : [...values, value]; -} - -function areSuggestionsEqual( - previous: readonly PersistentSuggestion[], - next: readonly PersistentSuggestion[], -): boolean { - if (previous.length !== next.length) { - return false; - } - - for (let index = 0; index < previous.length; index += 1) { - const previousSuggestion = previous[index]; - const nextSuggestion = next[index]; - if ( - previousSuggestion.id !== nextSuggestion.id || - previousSuggestion.kind !== nextSuggestion.kind || - previousSuggestion.blockId !== nextSuggestion.blockId || - previousSuggestion.action !== nextSuggestion.action || - previousSuggestion.author !== nextSuggestion.author || - previousSuggestion.authorType !== nextSuggestion.authorType || - previousSuggestion.createdAt !== nextSuggestion.createdAt || - previousSuggestion.model !== nextSuggestion.model || - previousSuggestion.sessionId !== nextSuggestion.sessionId - ) { - return false; - } - if ( - previousSuggestion.kind === "text" && - nextSuggestion.kind === "text" && - (previousSuggestion.offset !== nextSuggestion.offset || - previousSuggestion.length !== nextSuggestion.length) - ) { - return false; - } - if ( - previousSuggestion.kind === "block" && - nextSuggestion.kind === "block" && - JSON.stringify(previousSuggestion.previousState) !== - JSON.stringify(nextSuggestion.previousState) - ) { - return false; - } - } - - return true; -} - -function areAIControllerStatesEqual( - previous: AIControllerState, - next: AIControllerState, -): boolean { - if ( - previous.status !== next.status || - previous.activeSessionId !== next.activeSessionId || - previous.suggestMode !== next.suggestMode || - previous.commandMenuOpen !== next.commandMenuOpen || - previous.lastRoute !== next.lastRoute - ) { - return false; - } - - if (!areGenerationsEqual(previous.activeGeneration, next.activeGeneration)) { - return false; - } - - if ( - !areEphemeralSuggestionsEqual( - previous.ephemeralSuggestion, - next.ephemeralSuggestion, - ) - ) { - return false; - } - - return areSessionsEqual(previous.sessions, next.sessions); -} - -function areGenerationsEqual( - previous: AIControllerState["activeGeneration"], - next: AIControllerState["activeGeneration"], -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - - if ( - previous.id !== next.id || - previous.zoneId !== next.zoneId || - previous.blockId !== next.blockId || - previous.target !== next.target || - previous.sessionId !== next.sessionId || - previous.surface !== next.surface || - previous.prompt !== next.prompt || - previous.status !== next.status || - previous.tokenCount !== next.tokenCount || - previous.undoGroupId !== next.undoGroupId || - previous.text !== next.text || - previous.commandId !== next.commandId || - previous.contentFormat !== next.contentFormat || - previous.route !== next.route || - previous.mutationMode !== next.mutationMode || - previous.planState !== next.planState || - previous.targetKind !== next.targetKind || - !areStructuredValuesEqual(previous.structuredPreview, next.structuredPreview) || - !areStructuredValuesEqual(previous.reviewItems, next.reviewItems) || - !areStructuredValuesEqual(previous.plan, next.plan) || - !areStructuredValuesEqual(previous.debug, next.debug) - ) { - return false; - } - - if (!areStringArraysEqual(previous.suggestionIds, next.suggestionIds)) { - return false; - } - - if (previous.steps.length !== next.steps.length) { - return false; - } - - for (let index = 0; index < previous.steps.length; index += 1) { - const previousStep = previous.steps[index]; - const nextStep = next.steps[index]; - if ( - previousStep.index !== nextStep.index || - previousStep.type !== nextStep.type || - previousStep.toolName !== nextStep.toolName || - previousStep.toolCallId !== nextStep.toolCallId || - previousStep.status !== nextStep.status || - previousStep.input !== nextStep.input || - previousStep.output !== nextStep.output - ) { - return false; - } - } - - return true; -} - -function areSessionsEqual( - previous: readonly AISession[], - next: readonly AISession[], -): boolean { - if (previous.length !== next.length) { - return false; - } - for (let index = 0; index < previous.length; index += 1) { - const previousSession = previous[index]; - const nextSession = next[index]; - if ( - !previousSession || - !nextSession || - previousSession.id !== nextSession.id || - previousSession.surface !== nextSession.surface || - previousSession.status !== nextSession.status || - previousSession.createdAt !== nextSession.createdAt || - previousSession.updatedAt !== nextSession.updatedAt || - previousSession.activeTurnId !== nextSession.activeTurnId || - !areStructuredValuesEqual(previousSession.target, nextSession.target) || - !areStructuredValuesEqual(previousSession.anchor, nextSession.anchor) || - !areStructuredValuesEqual( - previousSession.contextualPrompt, - nextSession.contextualPrompt, - ) || - !areStructuredValuesEqual(previousSession.turns, nextSession.turns) || - !areStructuredValuesEqual(previousSession.promptHistory, nextSession.promptHistory) || - !areStringArraysEqual( - previousSession.generationIds, - nextSession.generationIds, - ) || - !areStringArraysEqual( - previousSession.pendingSuggestionIds, - nextSession.pendingSuggestionIds, - ) || - !areStringArraysEqual( - previousSession.pendingReviewItemIds, - nextSession.pendingReviewItemIds, - ) || - !areStructuredValuesEqual(previousSession.metrics, nextSession.metrics) - ) { - return false; - } - } - return true; -} - -function areInlineHistorySnapshotsEqual( - previous: AIInlineHistorySnapshot, - next: AIInlineHistorySnapshot, -): boolean { - return ( - previous.activeSessionId === next.activeSessionId && - previous.documentVersion === next.documentVersion && - previous.kind === next.kind && - areSessionsEqual(previous.sessions, next.sessions) - ); -} - -function didInlineHistoryCheckpointChange( - previousState: AIControllerState, - nextState: AIControllerState, -): boolean { - return !areStructuredValuesEqual( - buildInlineHistoryCheckpoint(previousState), - buildInlineHistoryCheckpoint(nextState), - ); -} - -function buildInlineHistoryCheckpoint( - state: AIControllerState, -): { - activeSessionId: string | null; - sessions: Array<{ - id: string; - isOpen: boolean; - target: AISessionSelectionSnapshot | null; - latestSettledTurn: { - id: string; - prompt: string; - selection: AISessionSelectionSnapshot | null; - } | null; - settledTurnCount: number; - }>; -} { - const inlineSessions = state.sessions.filter( - (session) => session.surface === "inline-edit", - ); - return { - activeSessionId: state.activeSessionId ?? null, - sessions: inlineSessions.map((session) => { - const settledTurns = session.turns.filter( - (turn) => turn.status !== "streaming", - ); - const latestSettledTurn = settledTurns[settledTurns.length - 1] ?? null; - return { - id: session.id, - isOpen: session.contextualPrompt?.composer.isOpen ?? false, - target: - session.contextualPrompt?.anchor.selectionSnapshot ?? - (session.target.kind === "selection" - ? resolveSessionSelectionSnapshot(session.target.selection) - : null), - latestSettledTurn: latestSettledTurn - ? { - id: latestSettledTurn.id, - prompt: latestSettledTurn.prompt, - selection: latestSettledTurn.selection ?? null, - } - : null, - settledTurnCount: settledTurns.length, - }; - }), - }; -} - -function countSettledInlineTurns( - snapshot: AIInlineHistorySnapshot, - sessionId?: string | null, -): number { - if (sessionId) { - const session = snapshot.sessions.find( - (item) => item.id === sessionId && item.surface === "inline-edit", - ); - if (!session) { - return 0; - } - return session.turns.filter((turn) => turn.status !== "streaming").length; - } - return snapshot.sessions - .filter((session) => session.surface === "inline-edit") - .reduce( - (count, session) => - count + session.turns.filter((turn) => turn.status !== "streaming").length, - 0, - ); -} - -function hasStreamingInlineTurns( - snapshot: AIInlineHistorySnapshot, - sessionId?: string | null, -): boolean { - if (sessionId) { - const session = snapshot.sessions.find( - (item) => item.id === sessionId && item.surface === "inline-edit", - ); - return session?.turns.some((turn) => turn.status === "streaming") ?? false; - } - return snapshot.sessions - .filter((session) => session.surface === "inline-edit") - .some((session) => - session.turns.some((turn) => turn.status === "streaming"), - ); -} - -function resolveInlineShortcutHistoryState( - snapshot: AIInlineHistorySnapshot, - sessionId: string | null, -): AIInlineShortcutHistoryState | null { - const session = sessionId - ? snapshot.sessions.find( - (item) => item.id === sessionId && item.surface === "inline-edit", - ) ?? null - : null; - if (!session) { - return { - sessionId: null, - phase: "none", - turnCount: 0, - turnId: null, - }; - } - const durableTurns = session.turns.filter( - (turn) => turn.status !== "streaming" && turn.status !== "cancelled", - ); - if (durableTurns.length === 0) { - return { - sessionId: null, - phase: "none", - turnCount: 0, - turnId: null, - }; - } - const latestTurn = durableTurns[durableTurns.length - 1] ?? null; - if (!latestTurn) { - return null; - } - if (latestTurn.status === "review") { - return { - sessionId, - phase: "review", - turnCount: durableTurns.length, - turnId: latestTurn.id, - }; - } - if ( - latestTurn.status === "accepted" || - latestTurn.status === "rejected" - ) { - return { - sessionId, - phase: "resolved", - turnCount: durableTurns.length, - turnId: latestTurn.id, - resolution: latestTurn.status, - }; - } - return null; -} - -function areInlineShortcutHistoryStatesEqual( - left: AIInlineShortcutHistoryState, - right: AIInlineShortcutHistoryState, -): boolean { +): AIReviewController | null { return ( - left.sessionId === right.sessionId && - left.phase === right.phase && - left.turnCount === right.turnCount && - left.turnId === right.turnId && - left.resolution === right.resolution - ); -} - -function shouldReplaceInlineShortcutWaypointRepresentative( - state: AIInlineShortcutHistoryState, - currentSnapshot: AIInlineHistorySnapshot | null, - nextSnapshot: AIInlineHistorySnapshot, -): boolean { - if (!currentSnapshot) { - return true; - } - const currentSession = state.sessionId - ? currentSnapshot.sessions.find( - (session) => - session.id === state.sessionId && session.surface === "inline-edit", - ) ?? null - : null; - const nextSession = state.sessionId - ? nextSnapshot.sessions.find( - (session) => - session.id === state.sessionId && session.surface === "inline-edit", + editor.internals.getSlot( + AI_REVIEW_CONTROLLER_SLOT, ) ?? null - : null; - if (state.phase === "review") { - const currentOpen = currentSession?.contextualPrompt?.composer.isOpen === true; - const nextOpen = nextSession?.contextualPrompt?.composer.isOpen === true; - if (currentOpen !== nextOpen) { - return nextOpen; - } - } - if (state.phase === "resolved") { - const currentOpen = currentSession?.contextualPrompt?.composer.isOpen === true; - const nextOpen = nextSession?.contextualPrompt?.composer.isOpen === true; - if (currentOpen !== nextOpen) { - return !nextOpen; - } - } - return true; -} - -function areEphemeralSuggestionsEqual( - previous: AIControllerState["ephemeralSuggestion"], - next: AIControllerState["ephemeralSuggestion"], -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - - return ( - previous.id === next.id && - previous.blockId === next.blockId && - previous.offset === next.offset && - previous.text === next.text && - previous.type === next.type && - previous.blockType === next.blockType && - previous.props === next.props - ); -} - -function areStringArraysEqual( - previous: readonly string[] | undefined, - next: readonly string[] | undefined, -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - if (previous.length !== next.length) { - return false; - } - - for (let index = 0; index < previous.length; index += 1) { - if (previous[index] !== next[index]) { - return false; - } - } - - return true; -} - -function areStructuredValuesEqual( - previous: unknown, - next: unknown, -): boolean { - if (previous === next) { - return true; - } - if (!previous || !next) { - return previous === next; - } - - try { - return JSON.stringify(previous) === JSON.stringify(next); - } catch { - return false; - } -} - -function buildSelectionReplacementOps( - editor: Editor, - selection: TextSelection, - insertedText: string, -): DocumentOp[] { - const range = selection.toRange(); - if (range.start.blockId === range.end.blockId) { - return [ - { - type: "replace-text", - blockId: range.start.blockId, - offset: range.start.offset, - length: range.end.offset - range.start.offset, - text: insertedText, - }, - ]; - } - const startId = range.start.blockId; - const endId = range.end.blockId; - const startText = editor.getBlock(startId)?.textContent() ?? ""; - const middleIds = range.blockRange.slice(1, -1); - const suffixDeltas = sliceInlineDeltasFromOffset( - editor.getBlock(endId)?.textDeltas() ?? [], - range.end.offset, - ); - const ops: DocumentOp[] = []; - - if (range.start.offset < startText.length) { - ops.push({ - type: "delete-text", - blockId: startId, - offset: range.start.offset, - length: startText.length - range.start.offset, - }); - } - - if (range.end.offset > 0) { - ops.push({ - type: "delete-text", - blockId: endId, - offset: 0, - length: range.end.offset, - }); - } - - for (const blockId of middleIds) { - ops.push({ - type: "delete-block", - blockId, - }); - } - - let insertionOffset = range.start.offset; - if (insertedText.length > 0) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: insertedText, - }); - insertionOffset += insertedText.length; - } - - for (const delta of suffixDeltas) { - ops.push({ - type: "insert-text", - blockId: startId, - offset: insertionOffset, - text: delta.insert, - marks: delta.attributes, - }); - insertionOffset += delta.insert.length; - } - - ops.push({ - type: "delete-block", - blockId: endId, - }); - return ops; -} - -function sliceInlineDeltasFromOffset( - deltas: readonly { insert: string; attributes?: Record }[], - startOffset: number, -): Array<{ insert: string; attributes?: Record }> { - const sliced: Array<{ insert: string; attributes?: Record }> = []; - let offset = 0; - for (const delta of deltas) { - const length = delta.insert.length; - if (startOffset >= offset + length) { - offset += length; - continue; - } - const localStart = Math.max(0, startOffset - offset); - const text = delta.insert.slice(localStart); - if (text.length > 0) { - sliced.push( - delta.attributes - ? { insert: text, attributes: delta.attributes } - : { insert: text }, - ); - } - offset += length; - } - return sliced; -} - -function resolveSelectionText(editor: Editor, selection: TextSelection): string { - const range = selection.toRange(); - const blockIds = range.blockRange; - const parts = blockIds.map((blockId, index) => { - const block = editor.getBlock(blockId); - if (!block) return ""; - - let rawOffset = 0; - let resolved = ""; - const startOffset = index === 0 ? range.start.offset : 0; - const endOffset = - index === blockIds.length - 1 ? range.end.offset : Number.POSITIVE_INFINITY; - - for (const delta of block.textDeltas()) { - const length = delta.insert.length; - const rawStart = rawOffset; - const rawEnd = rawOffset + length; - rawOffset = rawEnd; - - if (endOffset <= rawStart || startOffset >= rawEnd) { - continue; - } - - const sliceStart = Math.max(0, startOffset - rawStart); - const sliceEnd = Math.min(length, endOffset - rawStart); - if (sliceEnd <= sliceStart) { - continue; - } - - const suggestion = delta.attributes?.suggestion as - | { action?: string } - | undefined; - if (suggestion?.action === "delete") { - continue; - } - - resolved += delta.insert.slice(sliceStart, sliceEnd); - } - - return resolved; - }); - - return parts.join("\n"); -} - -function shouldReplaceEmptyMarkdownTarget( - block: ReturnType, -): boolean { - if (!block) { - return false; - } - - return ( - block.type === "paragraph" && - isVisuallyEmptyInlineText(block.textContent({ resolved: true })) ); } - -function shouldTrimLeadingBlankBlockGenerationText( - block: ReturnType, -): boolean { - if (!block) { - return false; - } - return isVisuallyEmptyInlineText(block.textContent({ resolved: true })); -} - -function trimLeadingBlankBlockGenerationText(text: string): string { - return text.replace(/^(?:[ \t]*\r?\n)+/, ""); -} - -function isVisuallyEmptyInlineText(text: string): boolean { - return text.replace(/\u200B/g, "").trim().length === 0; -} diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts new file mode 100644 index 0000000..c8ccc15 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart1.ts @@ -0,0 +1,64 @@ +// @ts-nocheck +import type { + AICommandBinding, + AICommandContext, + AIControllerState, + AIStreamEvent, +} from "../types"; +import { + resolveActiveBlockId, + resolveSelectionText, +} from "./extensionHelpers"; +import { sessionControllerMethods } from "./controllers/sessionControllerMethods"; + +export const aiControllerMethodsPart1 = { + destroy(this: any): void { + this._unsubscribeInlineCompletion?.(); + this._unsubscribeInlineCompletion = null; + this._unsubscribeHistoryApplied?.(); + this._unsubscribeHistoryApplied = null; + this._unsubscribeUndoHistoryMetadata?.(); + this._unsubscribeUndoHistoryMetadata = null; + }, + + getState(this: any): AIControllerState { + return this._state; + }, + + subscribe(this: any, listener: () => void): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + }, + + getStreamEvents(this: any): readonly AIStreamEvent[] { + return this._streamEvents; + }, + + subscribeStreamEvents(this: any, listener: () => void): () => void { + this._streamEventListeners.add(listener); + return () => this._streamEventListeners.delete(listener); + }, + + getCommands(this: any): readonly AICommandBinding[] { + return this._registry.list(this.getCommandContext()); + }, + + getCommandContext(this: any): AICommandContext { + const selection = this._editor.selection; + const blockId = resolveActiveBlockId(selection); + return { + editor: this._editor, + selection, + selectedText: + selection?.type === "text" + ? resolveSelectionText(this._editor, selection) + : "", + blockType: blockId + ? (this._editor.getBlock(blockId)?.type ?? null) + : null, + blockId, + }; + }, + + ...sessionControllerMethods, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts new file mode 100644 index 0000000..bfcb297 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart10.ts @@ -0,0 +1,477 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart10 = { +_resolvePlanValidationTargetKind(this: any, blockId: string): AITargetKind { + const blockType = this._editor.getBlock(blockId)?.type ?? null; + if (blockType === "database") { + return "database"; + } + if (blockType === "table") { + return "table"; + } + return "block"; + }, + +_verifyMarkdownFastApplyResult(this: any, + blockIds: readonly string[], + markdown: string, + ): { valid: boolean; reason?: string } { + if (markdown.trim().length === 0) { + return { valid: false, reason: "empty-merged-markdown" }; + } + const startBlockId = blockIds[0]; + const verificationResult = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: markdown, + position: startBlockId ? { before: startBlockId } : undefined, + surface: "ai-markdown-fast-apply-verify", + }); + if (verificationResult.blocks.length === 0) { + return { + valid: false, + reason: "markdown-parse-produced-no-blocks", + }; + } + return { valid: true }; + }, + +_verifyFlowPatchPlanResult(this: any, + plan: { + edits: Array<{ + locator: { blockId?: string; blockIds?: string[] }; + }>; + }, + ops: readonly DocumentOp[], + scopeBlockIds: readonly string[], + ): { + valid: boolean; + reason?: string; + untouchedBlockMutationCount: number; + } { + const targetedBlockIds = new Set( + plan.edits.flatMap((edit) => [ + ...(edit.locator.blockId ? [edit.locator.blockId] : []), + ...(edit.locator.blockIds ?? []), + ]), + ); + const scopeSet = new Set(scopeBlockIds); + const mutatedExistingBlockIds = new Set(); + const outOfScopeMutations = new Set(); + const createdBlockIds = new Set(); + + for (const op of ops) { + if (op.type === "insert-block") { + createdBlockIds.add(op.blockId); + } + for (const blockId of this._readBlockIdsFromOp(op)) { + if (scopeSet.has(blockId)) { + mutatedExistingBlockIds.add(blockId); + } else if ( + !createdBlockIds.has(blockId) && + op.type !== "insert-block" + ) { + outOfScopeMutations.add(blockId); + } + } + } + + if (outOfScopeMutations.size > 0) { + return { + valid: false, + reason: `flow-patch-mutated-outside-scope:${[...outOfScopeMutations].join(",")}`, + untouchedBlockMutationCount: 0, + }; + } + + const untouchedBlockMutationCount = [...mutatedExistingBlockIds].filter( + (blockId) => !targetedBlockIds.has(blockId), + ).length; + return { + valid: untouchedBlockMutationCount === 0, + reason: + untouchedBlockMutationCount > 0 + ? "flow-patch-mutated-untargeted-blocks" + : undefined, + untouchedBlockMutationCount, + }; + }, + +_buildMarkdownScopedReplacementOps(this: any, + blockIds: readonly string[], + text: string, + ): DocumentOp[] { + const startBlockId = blockIds[0]; + if (!startBlockId) { + return []; + } + const { ops } = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: text, + position: { before: startBlockId }, + surface: "ai-markdown-fast-apply", + }); + return [ + ...ops, + ...blockIds.map( + (currentBlockId) => + ({ + type: "delete-block", + blockId: currentBlockId, + }) satisfies DocumentOp, + ), + ]; + }, + +_summarizeFastApplyFallbackOps(this: any, + kind: "scoped-replacement" | "plain-markdown", + ops: readonly DocumentOp[], + targetBlockCount?: number, + ): { + kind: "scoped-replacement" | "plain-markdown"; + opsCount: number; + insertedBlockCount: number; + deletedBlockCount: number; + targetBlockCount?: number; + } { + let insertedBlockCount = 0; + let deletedBlockCount = 0; + for (const op of ops) { + if (op.type === "insert-block") { + insertedBlockCount += 1; + } else if (op.type === "delete-block") { + deletedBlockCount += 1; + } + } + return { + kind, + opsCount: ops.length, + insertedBlockCount, + deletedBlockCount, + targetBlockCount, + }; + }, + +_readBlockIdsFromOp(this: any, op: DocumentOp): string[] { + const blockIds = new Set(); + if ("blockId" in op && typeof op.blockId === "string") { + blockIds.add(op.blockId); + } + if ("targetBlockId" in op && typeof op.targetBlockId === "string") { + blockIds.add(op.targetBlockId); + } + if ("sourceBlockId" in op && typeof op.sourceBlockId === "string") { + blockIds.add(op.sourceBlockId); + } + return [...blockIds]; + }, + +_recordFastApplyDebug(this: any, + overrides: Partial< + NonNullable["fastApply"]> + >, + ): void { + const activeGeneration = this._state.activeGeneration; + if (!activeGeneration?.debug) { + return; + } + const currentFastApply = activeGeneration.debug.fastApply ?? { + attempted: false, + succeeded: false, + }; + this._resolveActiveGeneration({ + debug: { + ...activeGeneration.debug, + fastApply: { + ...currentFastApply, + ...overrides, + }, + }, + }); + }, + +_applySuggestedMarkdownPlaceholderReplacement(this: any, + blockId: string, + text: string, + sessionId?: string, + replaceTargetBlock?: boolean, + replaceBlockIds?: readonly string[], + ): DocumentOp[] | null { + const targetBlock = this._editor.getBlock(blockId); + if ( + !replaceTargetBlock && + !shouldReplaceEmptyMarkdownTarget(targetBlock) + ) { + return null; + } + + const { ops } = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: text, + position: { before: blockId }, + surface: "ai-markdown", + }); + if (ops.length === 0) { + return null; + } + + const deleteBlockIds = resolveReplacementDeleteBlockIds( + this._editor, + blockId, + replaceBlockIds, + ); + const replacementOps = [ + ...ops, + ...deleteBlockIds.map((nextBlockId) => ({ + type: "delete-block" as const, + blockId: nextBlockId, + })), + ] satisfies DocumentOp[]; + this._applySuggestedAIOps(replacementOps, sessionId); + return replacementOps; + }, + +_refreshStreamingMarkdownBlockPreview(this: any, + blockId: string, + text: string, + mutationMode: NonNullable, + sessionId: string | undefined, + baselineSuggestionIds: ReadonlySet, + previewSuggestionIds: readonly string[], + previousNormalizedText: string, + replaceTargetBlock?: boolean, + replaceBlockIds?: readonly string[], + ): { suggestionIds: string[]; normalizedText: string } { + const normalizedText = normalizeFlowMarkdownOutput(text); + if (normalizedText === previousNormalizedText) { + return { + suggestionIds: [...previewSuggestionIds], + normalizedText, + }; + } + + this._rejectPreviewSuggestions(previewSuggestionIds); + + if ( + normalizedText.trim().length === 0 && + !replaceTargetBlock && + (replaceBlockIds?.length ?? 0) === 0 + ) { + return { + suggestionIds: [], + normalizedText, + }; + } + + this._commitBufferedBlockGeneration( + blockId, + normalizedText, + mutationMode, + "markdown", + sessionId, + { replaceTargetBlock, replaceBlockIds }, + ); + + return { + suggestionIds: this.getSuggestions() + .map((item) => item.id) + .filter( + (suggestionId) => !baselineSuggestionIds.has(suggestionId), + ), + normalizedText, + }; + }, + +_commitStructuredPlan(this: any, + ops: DocumentOp[], + reviewSafe: boolean, + mutationMode: NonNullable, + adapterId: NonNullable, + blockClass: NonNullable, + transportKind: NonNullable, + ): AIMutationReceipt { + if (ops.length === 0) { + return buildMutationReceipt({ + status: "noop", + ops, + adapterId, + blockClass, + transportKind, + }); + } + + if (mutationMode === "direct-stream") { + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + return buildMutationReceipt({ + status: "applied", + ops, + adapterId, + blockClass, + transportKind, + }); + } + + if (reviewSafe) { + this._applySuggestedAIOps(ops); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId, + blockClass, + transportKind, + }); + } + return buildMutationReceipt({ + status: "staged_review", + ops, + adapterId, + blockClass, + transportKind, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts new file mode 100644 index 0000000..227b3a7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart11.ts @@ -0,0 +1,454 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart11 = { +_buildFallbackMutationReceipt(this: any, input: { + currentText: string; + suggestionIds: readonly string[]; + reviewItems: readonly StructuralReviewItem[]; + planExecutionIssueCount: number; + adapterId: NonNullable; + blockClass: NonNullable; + transportKind: NonNullable; + }): AIMutationReceipt { + if (input.planExecutionIssueCount > 0) { + return buildMutationReceipt({ + status: "invalid", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + issues: ["The generated mutation plan could not be executed."], + }); + } + if (input.reviewItems.length > 0) { + return buildMutationReceipt({ + status: "staged_review", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + }); + } + if (input.suggestionIds.length > 0) { + return buildMutationReceipt({ + status: "staged_suggestions", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + }); + } + return buildMutationReceipt({ + status: input.currentText.trim().length > 0 ? "applied" : "noop", + adapterId: input.adapterId, + blockClass: input.blockClass, + transportKind: input.transportKind, + }); + }, + +async _buildWorkingSet(this: any, + toolRuntime: ToolRuntime, + route: ReturnType, + target: GenerationTarget, + blockId: string, + prompt: string, + ): Promise { + const selectionSignature = this._createSelectionSignature( + this._editor.selection, + ); + if (target.type === "selection") { + const trackedBlockIds = [ + ...new Set(target.selection.toRange().blockRange), + ]; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "selection", + routeConfidence: route.confidence, + context: { + selection: target.selection, + selectedText: resolveSelectionText( + this._editor, + target.selection, + ), + }, + trackedBlockIds, + blockRevisions: this._captureBlockRevisions(trackedBlockIds), + selectionSignature, + }; + } + + if (route.useCursorContext) { + const retrievedSpan = + await this._resolveMarkdownFastApplyRetrievedSpan( + toolRuntime, + route, + blockId, + prompt, + ); + if ( + route.applyStrategy === "markdown-fast-apply" && + retrievedSpan + ) { + const context = (await toolRuntime.executeTool( + "get_context", + { + format: "markdown", + includeSelection: true, + includeSuggestions: this._state.suggestMode, + range: retrievedSpan.range, + }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "cursor-context", + context: { + ...context, + retrievedSpan, + }, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: retrievedSpan.blockIds.length, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(retrievedSpan.blockIds)], + blockRevisions: this._captureBlockRevisions( + retrievedSpan.blockIds, + ), + selectionSignature, + }; + } + const context = (await toolRuntime.executeTool( + "get_cursor_context", + { includeSuggestions: this._state.suggestMode }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + const trackedBlockIds = [ + blockId, + ...(context.surroundingBlocks ?? []).map((block) => block.id), + ]; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "cursor-context", + context, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: + context.surroundingBlocks?.length ?? 0, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(trackedBlockIds)], + blockRevisions: this._captureBlockRevisions(trackedBlockIds), + selectionSignature, + }; + } + + if (route.useDocumentSummary) { + const retrievedSpan = + await this._resolveMarkdownFastApplyRetrievedSpan( + toolRuntime, + route, + blockId, + prompt, + ); + if ( + route.applyStrategy === "markdown-fast-apply" && + retrievedSpan + ) { + const context = (await toolRuntime.executeTool( + "get_context", + { + format: "markdown", + includeSelection: true, + includeSuggestions: this._state.suggestMode, + range: retrievedSpan.range, + }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "document-summary", + context: { + ...context, + retrievedSpan, + }, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: retrievedSpan.blockIds.length, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(retrievedSpan.blockIds)], + blockRevisions: this._captureBlockRevisions( + retrievedSpan.blockIds, + ), + selectionSignature, + }; + } + const context = (await toolRuntime.executeTool( + "get_context", + { + format: "markdown", + includeSelection: true, + includeSuggestions: this._state.suggestMode, + range: { + startBlockId: blockId, + endBlockId: blockId, + }, + }, + {} as never, + )) as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + const trackedBlockIds = [ + blockId, + ...(context.surroundingBlocks ?? []).map((block) => block.id), + ]; + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "document-summary", + context, + routeConfidence: refineRouteWithNavigator(route, { + surroundingBlockCount: + context.surroundingBlocks?.length ?? 0, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }).confidence, + trackedBlockIds: [...new Set(trackedBlockIds)], + blockRevisions: this._captureBlockRevisions(trackedBlockIds), + selectionSignature, + }; + } + + return { + documentVersion: this._documentVersion, + viewMode: this._state.suggestMode ? "raw" : "resolved", + source: "document-summary", + context: null, + routeConfidence: route.confidence, + trackedBlockIds: [blockId], + blockRevisions: this._captureBlockRevisions([blockId]), + selectionSignature, + }; + }, + +_refineRouteWithWorkingSet(this: any, + route: ReturnType, + workingSet: AIWorkingSetEnvelope | null, + ): ReturnType { + if (!workingSet?.context || typeof workingSet.context !== "object") { + return route; + } + const context = workingSet.context as { + activeBlockType?: string | null; + markdown?: string | null; + surroundingBlocks?: Array<{ id: string }>; + selectedText?: string | null; + structuredTarget?: { + target?: { + kind?: "block" | "table" | "database"; + }; + } | null; + }; + return refineRouteWithNavigator(route, { + surroundingBlockCount: context.surroundingBlocks?.length ?? 0, + selectedTextLength: context.selectedText?.length ?? 0, + activeBlockType: context.activeBlockType ?? null, + structuredTargetKind: + context.structuredTarget?.target?.kind ?? null, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts new file mode 100644 index 0000000..40340e4 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart12.ts @@ -0,0 +1,467 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart12 = { +_validateWorkingSet(this: any, + route: ReturnType, + target: GenerationTarget, + workingSet: AIWorkingSetEnvelope | null, + ): { valid: boolean; canRefresh: boolean; reason?: string } { + if (!workingSet) { + return { valid: true, canRefresh: false }; + } + + const selectionSignature = this._createSelectionSignature( + this._editor.selection, + ); + const selectionChanged = + workingSet.selectionSignature !== selectionSignature; + const revisionChanged = + workingSet.documentVersion !== this._documentVersion || + workingSet.trackedBlockIds.some( + (blockId) => + this._editor.getBlockRevision(blockId) !== + workingSet.blockRevisions[blockId], + ); + + if (!selectionChanged && !revisionChanged) { + return { valid: true, canRefresh: false }; + } + + if ( + route.lane === "selection-rewrite" || + route.lane === "cursor-context" + ) { + return { + valid: false, + canRefresh: false, + reason: selectionChanged + ? "selection-provenance-changed" + : "local-context-changed", + }; + } + + return { + valid: false, + canRefresh: target.type === "block", + reason: revisionChanged + ? "document-revision-mismatch" + : "selection-changed", + }; + }, + +_resolveMarkdownFastApplyWindow(this: any, + route: ReturnType, + blockId: string, + ): { + range: { startBlockId: string; endBlockId: string }; + blockIds: string[]; + } | null { + const blocks = Array.from(this._editor.blocks()); + const blockIndex = blocks.findIndex((block) => block.id === blockId); + if (blockIndex === -1) { + return null; + } + + const radius = + route.targetKind === "table" + ? 0 + : route.intent === "continue" + ? 0 + : route.intent === "rewrite" || + route.intent === "local-edit" + ? 1 + : 0; + const startIndex = Math.max(0, blockIndex - radius); + const endIndex = Math.min(blocks.length - 1, blockIndex + radius); + const blockIds = blocks + .slice(startIndex, endIndex + 1) + .map((block) => block.id); + return { + range: { + startBlockId: blockIds[0] ?? blockId, + endBlockId: blockIds[blockIds.length - 1] ?? blockId, + }, + blockIds, + }; + }, + +async _resolveMarkdownFastApplyRetrievedSpan(this: any, + toolRuntime: ToolRuntime, + route: ReturnType, + blockId: string, + prompt: string, + ): Promise { + if (route.applyStrategy !== "markdown-fast-apply") { + return null; + } + + try { + const retrieved = (await toolRuntime.executeTool( + "retrieve_document_spans", + { + query: prompt, + maxResults: 1, + includeSuggestions: this._state.suggestMode, + activeBlockId: blockId, + targetBlockId: blockId, + }, + {} as never, + )) as { + spans?: AIWorkingSetRetrievedSpan[]; + }; + const retrievedSpan = retrieved.spans?.[0] ?? null; + if (retrievedSpan?.blockIds?.length) { + return retrievedSpan; + } + } catch { + // Older test fixtures or stale builds may not register the retriever yet. + } + + const markdownWindow = this._resolveMarkdownFastApplyWindow( + route, + blockId, + ); + if (!markdownWindow) { + return null; + } + return { + id: `span:${markdownWindow.blockIds.join(":")}`, + blockIds: markdownWindow.blockIds, + range: markdownWindow.range, + blockTypes: [], + headingPath: [], + preview: "", + markdown: "", + score: 0, + rationale: "window-fallback", + neighbors: { + beforeBlockId: null, + afterBlockId: null, + }, + }; + }, + +_applySuggestedAIOps(this: any, + ops: DocumentOp[], + sessionId?: string, + options?: { undoGroupId?: string }, + ): void { + this._suggestedOperationRunner.apply(ops, sessionId, options); + }, + +_captureBlockRevisions(this: any, blockIds: string[]): Record { + return Object.fromEntries( + blockIds.map((trackedBlockId) => [ + trackedBlockId, + this._editor.getBlockRevision(trackedBlockId), + ]), + ); + }, + +_resolveContentFormat(this: any, + target: GenerationState["target"], + surface?: AISurface, + ): AIContentFormat { + if (target === "selection") { + return this._contentFormat.selectionRewrite; + } + return this._contentFormat.blockGeneration; + }, + +_buildTextBlockGenerationOps(this: any, + blockId: string, + text: string, + insertionOffset?: number, + ): DocumentOp[] { + const targetBlock = this._editor.getBlock(blockId); + const normalizedText = shouldTrimLeadingBlankBlockGenerationText( + targetBlock, + ) + ? trimLeadingBlankBlockGenerationText(text) + : text; + if (normalizedText.length === 0) { + return []; + } + return [ + { + type: "insert-text", + blockId, + offset: + insertionOffset ?? targetBlock?.textContent().length ?? 0, + text: normalizedText, + }, + ]; + }, + +_buildMarkdownBlockGenerationOps(this: any, + blockId: string, + text: string, + replaceTargetBlock?: boolean, + replaceBlockIds?: readonly string[], + ): DocumentOp[] { + const targetBlock = this._editor.getBlock(blockId); + if (!targetBlock) { + return []; + } + + const { ops } = buildDocumentWriteOps(this._editor, { + format: "markdown", + content: text, + position: { after: blockId }, + surface: "ai-markdown", + }); + if ( + !replaceTargetBlock && + !shouldReplaceEmptyMarkdownTarget(targetBlock) + ) { + return ops; + } + + const deleteBlockIds = resolveReplacementDeleteBlockIds( + this._editor, + blockId, + replaceBlockIds, + ); + return [ + ...ops, + ...deleteBlockIds.map((nextBlockId) => ({ + type: "delete-block" as const, + blockId: nextBlockId, + })), + ]; + }, + +_createSelectionSignature(this: any, + selection: SelectionState, + ): string | null { + if (!selection) { + return null; + } + if (selection.type === "text") { + return [ + "text", + selection.anchor.blockId, + selection.anchor.offset, + selection.focus.blockId, + selection.focus.offset, + String(selection.isCollapsed), + ].join(":"); + } + if (selection.type === "block") { + return `block:${selection.blockIds.join(",")}`; + } + if (selection.type === "cell") { + return [ + "cell", + selection.blockId, + selection.anchor.row, + selection.anchor.col, + selection.head.row, + selection.head.col, + ].join(":"); + } + return selection.type; + }, + +_setState(this: any, partial: Partial): void { + const previousState = this._state; + const nextState = { ...this._state, ...partial }; + if (areAIControllerStatesEqual(previousState, nextState)) { + return; + } + this._state = nextState; + if ( + !this._isRestoringInlineHistory && + !this._pendingInlineHistoryRestore + ) { + this._recordInlineHistorySnapshot(previousState, nextState); + } + this._editor.requestDecorationUpdate(); + this._emit(); + }, + +_resolveActiveGeneration(this: any, + overrides: Partial, + ): void { + const activeGeneration = this._state.activeGeneration; + if (!activeGeneration) { + return; + } + + this._setState({ + activeGeneration: { + ...activeGeneration, + ...overrides, + plan: + overrides.planState === "none" || + overrides.planState === "rejected" + ? null + : (overrides.plan ?? activeGeneration.plan), + reviewItems: + overrides.planState === "none" || + overrides.planState === "rejected" + ? [] + : (overrides.reviewItems ?? + activeGeneration.reviewItems ?? + []), + structuredPreview: + overrides.planState === "none" || + overrides.planState === "rejected" + ? null + : (overrides.structuredPreview ?? + activeGeneration.structuredPreview ?? + null), + suggestionIds: + overrides.suggestionIds ?? + activeGeneration.suggestionIds ?? + [], + }, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts new file mode 100644 index 0000000..373e05a --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart13.ts @@ -0,0 +1,485 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart13 = { +_resolveSessionTurn(this: any, + sessionId: string, + turnId: string, + resolution: AISessionResolution, + options?: { finalizeSession?: boolean }, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + const turn = session?.turns.find((item) => item.id === turnId); + if (!session || !turn) { + return false; + } + const isBottomChatDocumentTurn = + session.surface === "bottom-chat" && + (turn.target === "document" || + turn.operation?.kind === "document-transform" || + (turn.operation?.kind === "rewrite-selection" && + turn.operation.target.kind === "scoped-range" && + (turn.operation.target.scope === "document" || + turn.operation.target.contentFormat === "markdown"))); + const turnUndoGroupId = isBottomChatDocumentTurn + ? turn.undoGroupId + : undefined; + const turnSuggestionResolutionOrigin = + turnUndoGroupId != null ? AI_SESSION_SUGGESTION_ORIGIN : undefined; + const undoHistoryBeforeSnapshot = this._undoHistoryMetadata + ? this._createInlineTurnUndoBeforeSnapshot(sessionId, turnId) + : null; + const refreshedInlineSelectionTarget = + session.surface === "inline-edit" && resolution === "accept" + ? (resolveAcceptedInlineSelectionTarget( + this._editor, + turn.operation, + turn.suggestionIds, + ) ?? resolveLiveInlineSelectionTarget(this._editor)) + : null; + const resolveSuggestionsForTurn = + resolution === "accept" + ? (suggestionIds: readonly string[]) => + acceptSuggestions(this._editor, suggestionIds, { + origin: turnSuggestionResolutionOrigin, + undoGroupId: turnUndoGroupId, + }) + : (suggestionIds: readonly string[]) => + rejectSuggestions(this._editor, suggestionIds, { + origin: turnSuggestionResolutionOrigin, + undoGroupId: turnUndoGroupId, + }); + const resolveReviewItems = + resolution === "accept" + ? (reviewItemIds: readonly string[]) => + this.acceptReviewItems(reviewItemIds) + : (reviewItemIds: readonly string[]) => + this.rejectReviewItems(reviewItemIds); + let resolved = false; + resolved = resolveSuggestionsForTurn(turn.suggestionIds) || resolved; + if ( + this._state.activeGeneration?.sessionId === sessionId && + this._state.activeGeneration.turnId === turnId && + this._state.activeGeneration.planState === "validated" && + turn.reviewItemIds.length > 0 + ) { + resolved = resolveReviewItems(turn.reviewItemIds) || resolved; + } + if (!resolved) { + return false; + } + this.clearStreamingReviewPreview(sessionId); + this._updateSessionTurn(sessionId, turnId, { + status: resolution === "accept" ? "accepted" : "rejected", + suggestionIds: [], + reviewItemIds: [], + structuredPreview: null, + anchor: refreshedInlineSelectionTarget + ? resolveSessionAnchor(refreshedInlineSelectionTarget.selection) + : undefined, + selection: refreshedInlineSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + }); + if (refreshedInlineSelectionTarget) { + this._updateSession(sessionId, { + target: refreshedInlineSelectionTarget, + anchor: resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ), + contextualPrompt: session.contextualPrompt + ? { + ...session.contextualPrompt, + anchor: resolveContextualPromptAnchor( + refreshedInlineSelectionTarget, + ), + } + : undefined, + }); + } + if (options?.finalizeSession === false) { + if (undoHistoryBeforeSnapshot) { + this._undoHistoryMetadata?.setCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + { + before: undoHistoryBeforeSnapshot, + after: createInlineHistorySnapshot( + this._editor, + this._state.sessions, + this._state.activeSessionId ?? null, + this._documentVersion, + { kind: "document-coupled" }, + ), + }, + ); + } + return true; + } + const nextSession = + this._state.sessions.find((item) => item.id === sessionId) ?? + session; + this._updateSession(sessionId, { + status: "complete", + contextualPrompt: closeInlineSessionPrompt(nextSession), + }); + if (undoHistoryBeforeSnapshot) { + this._undoHistoryMetadata?.setCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + { + before: undoHistoryBeforeSnapshot, + after: createInlineHistorySnapshot( + this._editor, + this._state.sessions, + this._state.activeSessionId ?? null, + this._documentVersion, + { kind: "document-coupled" }, + ), + }, + ); + } + return true; + }, + +_createInlineTurnUndoBeforeSnapshot(this: any, + sessionId: string, + turnId: string, + ): AIInlineHistorySnapshot { + const session = + this._state.sessions.find((item) => item.id === sessionId) ?? null; + if (session?.surface === "inline-edit") { + const reviewSnapshot = + this._findInlineHistorySnapshotForResolvedTurn(session, "undo"); + if (reviewSnapshot) { + const restoredSessions = reviewSnapshot.sessions.map( + (snapshotSession) => { + if ( + snapshotSession.id !== sessionId || + snapshotSession.surface !== "inline-edit" || + !snapshotSession.contextualPrompt + ) { + return snapshotSession; + } + const snapshotTurn = + snapshotSession.turns.find( + (turn) => turn.id === turnId, + ) ?? null; + if (!snapshotTurn) { + return snapshotSession; + } + return { + ...snapshotSession, + contextualPrompt: { + ...snapshotSession.contextualPrompt, + composer: { + ...snapshotSession.contextualPrompt + .composer, + draftPrompt: + snapshotSession.contextualPrompt + .composer.draftPrompt || + snapshotTurn.prompt, + }, + }, + }; + }, + ); + return createInlineHistorySnapshot( + this._editor, + restoredSessions, + sessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + } + } + const historySessions = this._state.sessions.map((session) => { + if ( + session.id !== sessionId || + session.surface !== "inline-edit" || + !session.contextualPrompt + ) { + return session; + } + const targetTurn = + session.turns.find((turn) => turn.id === turnId) ?? null; + if (targetTurn?.status !== "review") { + return session; + } + return { + ...session, + contextualPrompt: { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: true, + isSubmitting: false, + }, + }, + }; + }); + const nextActiveSessionId = historySessions.some( + (session) => + session.id === sessionId && + session.surface === "inline-edit" && + session.contextualPrompt?.composer.isOpen, + ) + ? sessionId + : (this._state.activeSessionId ?? null); + return createInlineHistorySnapshot( + this._editor, + historySessions, + nextActiveSessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + }, + +_updateSession(this: any, + sessionId: string, + overrides: Partial, + ): void { + const nextSessions = this._state.sessions.map((session) => + session.id !== sessionId + ? session + : { + ...session, + ...overrides, + contextualPrompt: + (overrides.contextualPrompt ?? + session.contextualPrompt) + ? { + ...(session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + )), + ...(overrides.contextualPrompt ?? {}), + anchor: { + ...( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).anchor, + ...(overrides.contextualPrompt + ?.anchor ?? {}), + }, + composer: { + ...( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).composer, + ...(overrides.contextualPrompt + ?.composer ?? {}), + isSubmitting: + overrides.contextualPrompt + ?.composer?.isSubmitting ?? + (overrides.status === + "streaming" + ? true + : overrides.status + ? false + : ( + session.contextualPrompt ?? + resolveContextualPromptState( + overrides.target ?? + session.target, + ) + ).composer + .isSubmitting), + }, + } + : undefined, + updatedAt: Date.now(), + metrics: { + ...session.metrics, + ...(overrides.metrics ?? {}), + }, + }, + ); + if (nextSessions === this._state.sessions) { + return; + } + this._setState({ + sessions: nextSessions, + activeSessionId: + this._state.activeSessionId === sessionId || + this._state.activeSessionId == null + ? sessionId + : this._state.activeSessionId, + }); + }, + +_recordSessionFastApplyMetrics(this: any, + sessionId: string, + fastApply: FastApplyDebugState | undefined, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return; + } + this._updateSession(sessionId, { + metrics: { + ...session.metrics, + fastApply: accumulateSessionFastApplyMetrics( + session.metrics.fastApply, + fastApply, + ), + }, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts new file mode 100644 index 0000000..357b9ea --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart14.ts @@ -0,0 +1,152 @@ +// @ts-nocheck +import type { AIControllerState, AIStreamEvent, AISession } from "../types"; +import { + areSessionsEqual, + areStructuredValuesEqual, + MAX_STREAM_EVENTS, +} from "./extensionHelpers"; +import { inlineHistoryRecording } from "./controllers/inlineHistoryRecording"; + +export const aiControllerMethodsPart14 = { + _updateSessionTurn( + this: any, + sessionId: string, + turnId: string, + overrides: Partial, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return; + } + const nextTurns = session.turns.map((turn) => + turn.id !== turnId + ? turn + : { + ...turn, + ...overrides, + }, + ); + if (areStructuredValuesEqual(session.turns, nextTurns)) { + return; + } + const pendingSuggestionIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), + ]; + const pendingReviewItemIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), + ]; + this._updateSession(sessionId, { + turns: nextTurns, + pendingSuggestionIds, + pendingReviewItemIds, + }); + }, + + _syncSessionsFromDocument(this: any): boolean { + if (this._state.sessions.length === 0) { + return false; + } + const nextSessions = this._state.sessions.map((session) => { + const nextTurns = session.turns.map((turn) => { + const suggestionIds = turn.suggestionIds.filter( + (sessionSuggestionId) => + this._suggestions.some( + (suggestion) => + suggestion.id === sessionSuggestionId, + ), + ); + const activeGenerationMatchesTurn = + this._state.activeGeneration?.sessionId === session.id && + this._state.activeGeneration.turnId === turn.id; + const activeGenerationForTurn = activeGenerationMatchesTurn + ? this._state.activeGeneration + : null; + const reviewItemIds = activeGenerationForTurn + ? (activeGenerationForTurn.reviewItems ?? []) + .map((item) => item.id) + .filter((id) => turn.reviewItemIds.includes(id)) + : []; + const structuredPreview = activeGenerationForTurn + ? (activeGenerationForTurn.structuredPreview ?? + turn.structuredPreview ?? + null) + : turn.reviewItemIds.length > 0 + ? (turn.structuredPreview ?? null) + : null; + return { + ...turn, + suggestionIds, + reviewItemIds, + structuredPreview, + }; + }); + const pendingSuggestionIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.suggestionIds)), + ]; + const pendingReviewItemIds = [ + ...new Set(nextTurns.flatMap((turn) => turn.reviewItemIds)), + ]; + const nextStatus = + pendingSuggestionIds.length === 0 && + pendingReviewItemIds.length === 0 && + session.status === "streaming" + ? "complete" + : session.status; + return { + ...session, + status: nextStatus, + turns: nextTurns, + pendingSuggestionIds, + pendingReviewItemIds, + }; + }); + if (areSessionsEqual(this._state.sessions, nextSessions)) { + return false; + } + this._setState({ + sessions: nextSessions, + }); + return true; + }, + + _setStreamEvents(this: any, nextEvents: readonly AIStreamEvent[]): void { + this._streamEvents = nextEvents; + this._emitStreamEvents(); + }, + + _appendStreamEvent(this: any, event: AIStreamEvent): void { + const lastEvent = this._streamEvents[this._streamEvents.length - 1]; + if ( + lastEvent?.type === "status" && + event.type === "status" && + lastEvent.generationId === event.generationId && + lastEvent.status === event.status + ) { + return; + } + const nextEvents = + this._streamEvents.length >= MAX_STREAM_EVENTS + ? [...this._streamEvents.slice(-(MAX_STREAM_EVENTS - 1)), event] + : [...this._streamEvents, event]; + this._setStreamEvents(nextEvents); + }, + + _emit(this: any): void { + for (const listener of this._listeners) { + listener(); + } + for (const listener of this._sessionListeners) { + listener(); + } + }, + + _emitStreamEvents(this: any): void { + for (const listener of this._streamEventListeners) { + listener(); + } + }, + + ...inlineHistoryRecording, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts new file mode 100644 index 0000000..89ee478 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart15.ts @@ -0,0 +1,6 @@ +// @ts-nocheck +import { inlineHistoryNavigation } from "./controllers/inlineHistoryNavigation"; + +export const aiControllerMethodsPart15 = { + ...inlineHistoryNavigation, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts new file mode 100644 index 0000000..ec9ae25 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart16.ts @@ -0,0 +1,451 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart16 = { +_findInlineHistorySnapshotForResolvedTurn(this: any, + session: AISession, + direction: AIInlineHistoryDirection, + ): AIInlineHistorySnapshot | null { + const latestTurnId = + session.turns[session.turns.length - 1]?.id ?? null; + if (!latestTurnId) { + return null; + } + for ( + let index = this._inlineHistory.length - 1; + index >= 0; + index -= 1 + ) { + const snapshot = this._inlineHistory[index]; + const snapshotSession = + snapshot?.sessions.find( + (item) => + item.id === session.id && + item.surface === "inline-edit", + ) ?? null; + if (!snapshotSession) { + continue; + } + const snapshotTurn = + snapshotSession.turns.find( + (turn) => turn.id === latestTurnId, + ) ?? null; + if (!snapshotTurn) { + continue; + } + if ( + direction === "undo" && + snapshotSession.contextualPrompt?.composer.isOpen && + snapshotTurn.status === "review" + ) { + return snapshot; + } + if ( + direction === "redo" && + !snapshotSession.contextualPrompt?.composer.isOpen && + (snapshotTurn.status === "accepted" || + snapshotTurn.status === "rejected") + ) { + return snapshot; + } + } + return null; + }, + +_resolveInlineHistoryTraversalSnapshot(this: any, + targetSnapshot: AIInlineHistorySnapshot, + ): AIInlineHistorySnapshot { + if (targetSnapshot.kind === "ui-local") { + return targetSnapshot; + } + const scopedSessionId = + targetSnapshot.sessionId ?? targetSnapshot.activeSessionId; + const targetState = resolveInlineShortcutHistoryState( + targetSnapshot, + scopedSessionId, + ); + if (!targetState) { + return targetSnapshot; + } + let resolvedSnapshot = targetSnapshot; + for (const snapshot of this._inlineHistory) { + if (snapshot.documentVersion !== targetSnapshot.documentVersion) { + continue; + } + const snapshotState = resolveInlineShortcutHistoryState( + snapshot, + scopedSessionId, + ); + if ( + !snapshotState || + !areInlineShortcutHistoryStatesEqual(snapshotState, targetState) + ) { + continue; + } + if ( + shouldReplaceInlineShortcutWaypointRepresentative( + targetState, + resolvedSnapshot, + snapshot, + ) + ) { + resolvedSnapshot = snapshot; + } + } + return resolvedSnapshot; + }, + +_resolveShortcutInlineHistoryTraversalSnapshot(this: any, + targetSnapshot: AIInlineHistorySnapshot, + fallbackSessionId?: string | null, + ): AIInlineHistorySnapshot { + const scopedSessionId = + targetSnapshot.sessionId ?? + targetSnapshot.activeSessionId ?? + fallbackSessionId ?? + null; + const targetState = resolveInlineShortcutHistoryState( + targetSnapshot, + scopedSessionId, + ); + if (targetState?.phase !== "none" || !scopedSessionId) { + return this._resolveInlineHistoryTraversalSnapshot(targetSnapshot); + } + return createInlineHistorySnapshot( + this._editor, + targetSnapshot.sessions.filter( + (session) => session.id !== scopedSessionId, + ), + targetSnapshot.activeSessionId === scopedSessionId + ? null + : targetSnapshot.activeSessionId, + targetSnapshot.documentVersion, + { kind: targetSnapshot.kind }, + ); + }, + +_scheduleQueuedInlineHistoryShortcutFlush(this: any): void { + if ( + this._queuedInlineHistoryShortcutFlushScheduled || + this._queuedInlineHistoryShortcutDirections.length === 0 + ) { + return; + } + this._queuedInlineHistoryShortcutFlushScheduled = true; + queueMicrotask(() => { + this._queuedInlineHistoryShortcutFlushScheduled = false; + if (this._pendingInlineHistoryRestore) { + this._scheduleQueuedInlineHistoryShortcutFlush(); + return; + } + const nextDirection = + this._queuedInlineHistoryShortcutDirections.shift() ?? null; + if (!nextDirection) { + return; + } + this._navigateInlineHistory(nextDirection, { shortcutOnly: true }); + if (this._queuedInlineHistoryShortcutDirections.length > 0) { + this._scheduleQueuedInlineHistoryShortcutFlush(); + } + }); + }, + +_resolvePendingInlineHistoryRestoreTargetIndex(this: any, + request: AIInlineHistoryRestoreRequest, + ): number { + const exactTargetIndex = this._inlineHistory.findIndex( + (snapshot) => snapshot.id === request.targetSnapshotId, + ); + if (exactTargetIndex >= 0) { + return exactTargetIndex; + } + if (!request.targetState) { + return -1; + } + let resolvedTargetIndex = -1; + const scopedSessionId = + request.sessionId ?? request.targetState.sessionId; + for (let index = 0; index < this._inlineHistory.length; index += 1) { + const snapshot = this._inlineHistory[index]; + if (!snapshot || snapshot.kind === "ui-local") { + continue; + } + if (snapshot.documentVersion !== request.targetDocumentVersion) { + continue; + } + const snapshotState = resolveInlineShortcutHistoryState( + snapshot, + scopedSessionId ?? null, + ); + if ( + !snapshotState || + !areInlineShortcutHistoryStatesEqual( + snapshotState, + request.targetState, + ) + ) { + continue; + } + if ( + resolvedTargetIndex < 0 || + shouldReplaceInlineShortcutWaypointRepresentative( + request.targetState, + this._inlineHistory[resolvedTargetIndex] ?? null, + snapshot, + ) + ) { + resolvedTargetIndex = index; + } + } + return resolvedTargetIndex; + }, + +_handleHistoryApplied(this: any, event: HistoryAppliedEvent): void { + if ( + this._pendingInlineHistoryRestore && + this._pendingInlineHistoryRestore.direction === event.kind + ) { + const targetIndex = + this._resolvePendingInlineHistoryRestoreTargetIndex( + this._pendingInlineHistoryRestore, + ); + if (targetIndex >= 0) { + this._inlineHistoryIndex = targetIndex; + const targetSnapshot = this._inlineHistory[targetIndex]!; + const resolvedTargetSnapshot = this._pendingInlineHistoryRestore + .shortcutOnly + ? this._resolveShortcutInlineHistoryTraversalSnapshot( + targetSnapshot, + this._pendingInlineHistoryRestore.sessionId ?? null, + ) + : this._resolveInlineHistoryTraversalSnapshot( + targetSnapshot, + ); + this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { + historyTraversal: true, + }); + } + this._pendingInlineHistoryRestore = null; + this._scheduleQueuedInlineHistoryShortcutFlush(); + return; + } + if (this._handledUndoHistoryRequestId === event.requestId) { + this._handledUndoHistoryRequestId = null; + return; + } + const selection = event.selection; + if (selection?.type !== "text" || selection.isCollapsed) { + return; + } + const matchingSession = [...this._state.sessions] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + session.status !== "cancelled" && + sessionSelectionMatches(session, selection), + ); + if (!matchingSession) { + return; + } + this._setInlineSessionComposerOpen(matchingSession.id, true, { + openReason: "history", + }); + }, + +_setInlineSessionComposerOpen(this: any, + sessionId: string, + isOpen: boolean, + options?: { openReason?: "user" | "history" }, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if ( + !session || + session.surface !== "inline-edit" || + !session.contextualPrompt + ) { + return; + } + const nextActiveSessionId = isOpen + ? sessionId + : this._state.activeSessionId === sessionId + ? null + : this._state.activeSessionId; + if ( + session.contextualPrompt.composer.isOpen === isOpen && + nextActiveSessionId === this._state.activeSessionId + ) { + return; + } + const nextSessions = this._state.sessions.map((item) => + item.id !== sessionId + ? item + : { + ...item, + contextualPrompt: { + ...item.contextualPrompt!, + composer: { + ...item.contextualPrompt!.composer, + isOpen, + openReason: isOpen + ? (options?.openReason ?? "user") + : item.contextualPrompt!.composer + .openReason, + }, + }, + updatedAt: Date.now(), + }, + ); + this._setState({ + sessions: nextSessions, + activeSessionId: nextActiveSessionId, + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts new file mode 100644 index 0000000..36f2497 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart2.ts @@ -0,0 +1,416 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart2 = { +canReuseSessionPrompt(this: any, + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return false; + } + if (session.surface !== "bottom-chat" || !session.operation) { + return true; + } + const nextOperation = + options?.operation ?? + resolveRequestedOperationForSession( + this._editor, + session, + prompt, + options, + this._documentVersion, + ); + return canReuseBottomChatSessionOperation( + session.operation, + nextOperation, + ); + }, + +resolveSession(this: any, + sessionId: string, + resolution: AISessionResolution, + ): boolean { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return false; + } + let resolved = false; + for (const turn of session.turns) { + resolved = + this._resolveSessionTurn(sessionId, turn.id, resolution, { + finalizeSession: false, + }) || resolved; + } + if (resolved) { + const nextSession = + this._state.sessions.find((item) => item.id === sessionId) ?? + session; + this._updateSession(sessionId, { + status: "complete", + pendingSuggestionIds: [], + pendingReviewItemIds: [], + contextualPrompt: closeInlineSessionPrompt(nextSession), + }); + } + return resolved; + }, + +acceptSession(this: any, sessionId: string): boolean { + return this.resolveSession(sessionId, "accept"); + }, + +rejectSession(this: any, sessionId: string): boolean { + return this.resolveSession(sessionId, "reject"); + }, + +cancelSession(this: any, sessionId: string): void { + if (this._state.activeGeneration?.sessionId === sessionId) { + this.cancelActiveGeneration(); + } + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + this._updateSession(sessionId, { + status: "cancelled", + contextualPrompt: session?.contextualPrompt + ? { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: false, + isSubmitting: false, + }, + } + : undefined, + }); + }, + +suspendInlineSession(this: any, sessionId: string): void { + this._setInlineSessionComposerOpen(sessionId, false); + }, + +resumeInlineSession(this: any, sessionId: string): void { + this._setInlineSessionComposerOpen(sessionId, true, { + openReason: "user", + }); + }, + +canUndoInlineHistory(this: any): boolean { + return this._inlineHistoryIndex > 0; + }, + +canRedoInlineHistory(this: any): boolean { + return ( + this._inlineHistoryIndex >= 0 && + this._inlineHistoryIndex < this._inlineHistory.length - 1 + ); + }, + +undoInlineHistory(this: any): boolean { + return this._navigateInlineHistory("undo"); + }, + +redoInlineHistory(this: any): boolean { + return this._navigateInlineHistory("redo"); + }, + +canHandleInlineHistoryShortcut(this: any, + direction: AIInlineHistoryDirection, + ): boolean { + if (this._pendingInlineHistoryRestore) { + return true; + } + return this._canHandleInlineHistoryShortcut(direction, { + shortcutOnly: true, + }); + }, + +handleInlineHistoryShortcut(this: any, direction: AIInlineHistoryDirection): boolean { + if (this._pendingInlineHistoryRestore) { + this._queuedInlineHistoryShortcutDirections.push(direction); + return true; + } + return this._navigateInlineHistory(direction, { shortcutOnly: true }); + }, + +async runCommand(this: any, + commandId: string, + options?: AICommandExecutionOptions, + ): Promise { + const ctx = this.getCommandContext(); + const command = this._registry.resolve(commandId); + if (!command) { + throw new Error(`Unknown AI command "${commandId}"`); + } + if (command.guard && !command.guard(ctx)) { + throw new Error( + `AI command "${command.label}" is not available in this context`, + ); + } + + const prompt = this._registry.resolvePrompt(command, ctx); + this._lastPrompt = prompt; + this._lastCommandId = command.id; + + if ( + command.target === "selection" && + ctx.selection?.type === "text" && + !ctx.selection.isCollapsed + ) { + return this._runSelectionGeneration( + prompt, + ctx.selection, + command.id, + options?.maxSteps, + ); + } + + const targetBlockId = + options?.blockId ?? + ctx.blockId ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!targetBlockId) { + throw new Error("Cannot run AI command without a target block"); + } + return this._runBlockGeneration( + prompt, + targetBlockId, + command.id, + options?.maxSteps, + ); + }, + +async runPrompt(this: any, + prompt: string, + options?: AICommandExecutionOptions, + ): Promise { + this._lastPrompt = prompt; + this._lastCommandId = null; + const promptTarget = resolvePromptTarget( + this._editor.selection, + options?.target, + ); + if (promptTarget === "selection") { + const selection = this._editor.selection; + if (selection?.type !== "text" || selection.isCollapsed) { + throw new Error( + "Cannot run a selection prompt without selected text", + ); + } + return this._runSelectionGeneration( + prompt, + selection, + undefined, + options?.maxSteps, + ); + } + if (promptTarget === "document") { + return this._runDocumentGeneration( + prompt, + options?.blockId, + undefined, + options?.maxSteps, + ); + } + const blockId = + options?.blockId ?? + resolveActiveBlockId(this._editor.selection) ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!blockId) { + throw new Error("Cannot run AI prompt without a target block"); + } + return this._runBlockGeneration( + prompt, + blockId, + undefined, + options?.maxSteps, + ); + }, + +async retryActiveGeneration(this: any): Promise { + const prompt = this._lastPrompt; + if (!prompt) return null; + this.rejectActiveGeneration(); + const active = this._state.activeGeneration; + const blockId = + active?.blockId ?? + resolveActiveBlockId(this._editor.selection) ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!blockId) return null; + if (active?.sessionId) { + const activeSession = this._state.sessions.find( + (session) => session.id === active.sessionId, + ); + const retryTarget = + activeSession?.target.kind === "document" + ? "document" + : (active?.target ?? "block"); + return this.runSessionPrompt(active.sessionId, prompt, { + blockId: retryTarget === "document" ? null : blockId, + target: retryTarget, + }); + } + if (this._lastCommandId) { + return this.runCommand(this._lastCommandId, { blockId }); + } + return this.runPrompt(prompt, { + blockId, + target: active?.target ?? "block", + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts new file mode 100644 index 0000000..d8b09be --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart3.ts @@ -0,0 +1,477 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart3 = { +acceptActiveGeneration(this: any): boolean { + const generation = this._state.activeGeneration; + if (!generation) { + return false; + } + + if (generation.suggestionIds && generation.suggestionIds.length > 0) { + const existingSession = + generation.sessionId != null + ? (this._state.sessions.find( + (session) => session.id === generation.sessionId, + ) ?? null) + : null; + const existingTurn = + generation.turnId != null + ? (existingSession?.turns.find( + (turn) => turn.id === generation.turnId, + ) ?? null) + : null; + const refreshSuggestionIds = existingTurn?.suggestionIds.length + ? existingTurn.suggestionIds + : generation.suggestionIds; + const refreshedInlineSelectionTarget = + generation.surface === "inline-edit" + ? (resolveAcceptedInlineSelectionTarget( + this._editor, + existingTurn?.operation ?? + generation.operation ?? + undefined, + refreshSuggestionIds, + ) ?? resolveLiveInlineSelectionTarget(this._editor)) + : null; + const accepted = acceptSuggestions( + this._editor, + generation.suggestionIds, + ); + if (accepted) { + this._resolveActiveGeneration({ + suggestionIds: [], + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "accepted", + suggestionIds: [], + structuredPreview: null, + anchor: refreshedInlineSelectionTarget + ? resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + selection: refreshedInlineSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineSelectionTarget.selection, + ) + : undefined, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingSuggestionIds: [], + ...(refreshedInlineSelectionTarget + ? { + target: refreshedInlineSelectionTarget, + anchor: resolveSessionAnchor( + refreshedInlineSelectionTarget.selection, + ), + contextualPrompt: + existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: resolveContextualPromptAnchor( + refreshedInlineSelectionTarget, + ), + } + : undefined, + } + : {}), + }); + } + } + return accepted; + } + + if (generation.planState !== "validated" || !generation.plan) { + return false; + } + + const execution = buildDocumentMutationPlanExecution( + this._editor, + generation.plan, + ); + if (execution.issues.length > 0) { + this._resolveActiveGeneration({ + planState: "rejected", + }); + return false; + } + + this._editor.apply(execution.ops, { origin: "ai", undoGroup: true }); + this._resolveActiveGeneration({ + planState: "none", + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "accepted", + reviewItemIds: [], + structuredPreview: null, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingReviewItemIds: [], + }); + } + return true; + }, + +rejectActiveGeneration(this: any): boolean { + const generation = this._state.activeGeneration; + if (!generation) return false; + + if (generation.suggestionIds && generation.suggestionIds.length > 0) { + const rejected = rejectSuggestions( + this._editor, + generation.suggestionIds, + ); + if (rejected) { + this._resolveActiveGeneration({ + suggestionIds: [], + planState: "rejected", + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "rejected", + suggestionIds: [], + structuredPreview: null, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingSuggestionIds: [], + }); + } + } + return rejected; + } + + if (generation.planState === "validated" && generation.plan) { + this._resolveActiveGeneration({ + status: "cancelled", + planState: "rejected", + structuredPreview: null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: "rejected", + reviewItemIds: [], + structuredPreview: null, + }, + ); + } + this._updateSession(generation.sessionId, { + status: "complete", + pendingReviewItemIds: [], + }); + } + return true; + } + + if (generation.status === "streaming") { + this.cancelActiveGeneration(); + } + + return this._editor.undoManager.undo(); + }, + +acceptReviewItem(this: any, id: string): boolean { + return this.acceptReviewItems([id]); + }, + +rejectReviewItem(this: any, id: string): boolean { + return this.rejectReviewItems([id]); + }, + +acceptReviewItems(this: any, ids: readonly string[]): boolean { + return this._applyReviewItems(ids, "accept"); + }, + +rejectReviewItems(this: any, ids: readonly string[]): boolean { + return this._applyReviewItems(ids, "reject"); + }, + +_applyReviewItems(this: any, + ids: readonly string[], + action: "accept" | "reject", + ): boolean { + const generation = this._state.activeGeneration; + if ( + !generation || + generation.planState !== "validated" || + !generation.plan || + !generation.reviewItems + ) { + return false; + } + + const reviewItems = resolveOrderedReviewItems( + generation.reviewItems, + ids, + ); + if (reviewItems.length === 0) { + return false; + } + + if (action === "accept") { + const selectedPlans = reviewItems.map((reviewItem) => + selectStructuralReviewItemPlan(generation.plan!, reviewItem), + ); + if (selectedPlans.some((plan) => !plan)) { + return false; + } + const resolvedSelectedPlans = selectedPlans.filter( + (plan): plan is NonNullable<(typeof selectedPlans)[number]> => + plan != null, + ); + + const selectedPlan = + resolvedSelectedPlans.length === 1 + ? resolvedSelectedPlans[0]! + : { + kind: "review_bundle" as const, + label: "Bulk review selection", + reason: "Apply selected review items together.", + plans: resolvedSelectedPlans, + }; + const execution = buildDocumentMutationPlanExecution( + this._editor, + selectedPlan, + ); + if (execution.issues.length > 0) { + return false; + } + + this._editor.apply(execution.ops, { + origin: "ai", + undoGroup: true, + }); + } + + let nextPlan: GenerationState["plan"] = generation.plan; + for (const reviewItem of sortReviewItemsForRemoval(reviewItems)) { + if (!nextPlan) { + break; + } + nextPlan = removeStructuralReviewItemPlan(nextPlan, reviewItem); + } + const nextReviewItems = nextPlan + ? buildStructuralReviewItems(this._editor, nextPlan) + : []; + this._resolveActiveGeneration({ + status: + nextPlan || action === "accept" + ? generation.status + : "cancelled", + planState: nextPlan + ? "validated" + : action === "accept" + ? "none" + : "rejected", + plan: nextPlan, + reviewItems: nextReviewItems, + structuredPreview: nextPlan + ? buildGenerationStructuredPreviewState(this._editor, { + planState: "validated", + plan: nextPlan, + }) + : null, + }); + if (generation.sessionId) { + if (generation.turnId) { + this._updateSessionTurn( + generation.sessionId, + generation.turnId, + { + status: nextPlan + ? "review" + : action === "accept" + ? "accepted" + : "rejected", + reviewItemIds: nextReviewItems.map((item) => item.id), + }, + ); + } + this._updateSession(generation.sessionId, { + status: + nextPlan || action === "accept" + ? generation.status === "streaming" + ? "streaming" + : "complete" + : "complete", + pendingReviewItemIds: nextReviewItems.map((item) => item.id), + }); + } + return true; + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts new file mode 100644 index 0000000..b7e0797 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart4.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +import { decorationControllerMethods } from "./controllers/decorationControllerMethods"; +import { generationRunnerMethods } from "./controllers/generationRunnerMethods"; +import { suggestionControllerMethods } from "./controllers/suggestionControllerMethods"; + +export const aiControllerMethodsPart4 = { + ...generationRunnerMethods, + ...decorationControllerMethods, + ...suggestionControllerMethods, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts new file mode 100644 index 0000000..99c14aa --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart5.ts @@ -0,0 +1,19 @@ +// @ts-nocheck +import { executeLocalOperation } from "./localOperationExecution"; +import type { AIRequestedOperation, GenerationState } from "../types"; +import type { GenerationExecutionContext, GenerationTarget } from "./extensionHelpers"; + +export const aiControllerMethodsPart5 = { + async _executeLocalOperation(this: any, input: { + prompt: string; + target: GenerationTarget; + blockId: string; + commandId?: string; + context?: GenerationExecutionContext; + abortController: AbortController; + baselineSuggestionIds: Set; + operation: AIRequestedOperation; + }): Promise { + return executeLocalOperation(this, input); + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts new file mode 100644 index 0000000..b86875a --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart6.ts @@ -0,0 +1,23 @@ +// @ts-nocheck +import { executeGeneration } from "./generationExecution"; +import type { GenerationState } from "../types"; +import type { GenerationExecutionContext, GenerationTarget } from "./extensionHelpers"; + +export const aiControllerMethodsPart6 = { + async _executeGeneration( + this: any, + prompt: string, + target: GenerationTarget, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + return executeGeneration(this, { + prompt, + target, + commandId, + maxSteps, + context, + }); + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts new file mode 100644 index 0000000..5cd65f9 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart7.ts @@ -0,0 +1,446 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart7 = { +_commitRequestedOperationResult(this: any, + operation: AIRequestedOperation, + text: string, + sessionId: string | undefined, + options: { + contentFormat: AIContentFormat; + applyStrategy?: AIApplyStrategy; + }, + ): AIMutationReceipt { + const conflictReason = resolveRequestedOperationConflict( + this._editor, + operation, + this._createSelectionSignature(this._editor.selection), + ); + if (conflictReason) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [conflictReason], + }); + } + + if (operation.kind === "rewrite-selection") { + const selection = resolveSelectionForRequestedOperation( + this._editor, + operation, + ); + if (!selection) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "The requested selection rewrite target is no longer available.", + ], + }); + } + const markdownBlockIds = + options.contentFormat === "markdown" && + operation.target.kind === "scoped-range" && + operation.target.blockIds.length > 0 + ? operation.target.blockIds + : null; + if (markdownBlockIds) { + return this._commitBufferedBlockGeneration( + markdownBlockIds[0], + text, + "persistent-suggestions", + "markdown", + sessionId, + { + applyStrategy: options.applyStrategy, + replaceTargetBlock: true, + replaceBlockIds: markdownBlockIds, + }, + ); + } + return this._commitSelectionRewrite( + selection, + text, + "persistent-suggestions", + sessionId, + ); + } + + if (operation.kind === "rewrite-block") { + const target = + operation.target.kind === "block" ? operation.target : null; + if (!target) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: ["The requested block rewrite target is invalid."], + }); + } + const selection = resolveFullBlockTextSelection( + this._editor, + target.blockId, + ); + if (selection && options.contentFormat === "text") { + return this._commitSelectionRewrite( + selection, + text, + "persistent-suggestions", + sessionId, + ); + } + return this._commitBufferedBlockGeneration( + target.blockId, + text, + "persistent-suggestions", + options.contentFormat, + sessionId, + { + applyStrategy: options.applyStrategy, + replaceTargetBlock: true, + }, + ); + } + + if (operation.kind === "document-transform") { + const target = + operation.target.kind === "document" ? operation.target : null; + if (!target) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "The requested document transform target is invalid.", + ], + }); + } + const replaceBlockIds = target.blockIds?.filter( + (blockId) => this._editor.getBlock(blockId) != null, + ); + if (target.transform === "remove") { + const deleteBlockIds = + replaceBlockIds && replaceBlockIds.length > 0 + ? replaceBlockIds + : this._editor.documentState.blockOrder.filter( + (blockId) => + this._editor.getBlock(blockId) != null, + ); + const ops = deleteBlockIds.map((blockId) => ({ + type: "delete-block" as const, + blockId, + })); + if (ops.length === 0) { + return buildMutationReceipt({ + status: "noop", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._applySuggestedAIOps(ops, sessionId); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + const targetBlockId = + target.activeBlockId ?? + replaceBlockIds?.[0] ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id ?? + null; + if (!targetBlockId) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "The requested document transform target is no longer available.", + ], + }); + } + return this._commitBufferedBlockGeneration( + targetBlockId, + text, + "persistent-suggestions", + options.contentFormat, + sessionId, + { + applyStrategy: options.applyStrategy, + replaceTargetBlock: + target.placement === "replace-blocks" || + target.placement === "replace-empty-block" || + (replaceBlockIds?.length ?? 0) > 0, + replaceBlockIds, + }, + ); + } + + const target = + operation.target.kind === "block" ? operation.target : null; + if (!target) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: ["The requested continuation target is invalid."], + }); + } + return this._commitBufferedBlockGeneration( + target.blockId, + text, + "persistent-suggestions", + "text", + sessionId, + { + insertionOffset: target.insertionOffset, + }, + ); + }, + +_commitSelectionRewrite(this: any, + selection: TextSelection, + text: string, + mutationMode: NonNullable, + sessionId?: string, + ): AIMutationReceipt { + const selectedText = resolveSelectionText(this._editor, selection); + const ops = buildSelectionReplacementOps(this._editor, selection, text); + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + contextChars: selectedText.length, + diffChars: text.length, + }); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._editor.selectTextRange(selection.anchor, selection.focus); + this._editor.deleteSelection({ origin: "ai" }); + const nextSelection = this._editor.selection; + if (nextSelection?.type !== "text") { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: selectedText.length, + diffChars: text.length, + fallbackReason: "selection-lost", + }); + return buildMutationReceipt({ + status: "invalid", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: ["Selection rewrite lost the active text selection."], + }); + } + const caret = nextSelection.anchor; + if (text.length > 0) { + this._editor.apply( + [ + { + type: "insert-text", + blockId: caret.blockId, + offset: caret.offset, + text, + }, + ], + { origin: "ai" }, + ); + } + this._editor.selectTextRange( + { + blockId: caret.blockId, + offset: caret.offset + text.length, + }, + { + blockId: caret.blockId, + offset: caret.offset + text.length, + }, + ); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + contextChars: selectedText.length, + diffChars: text.length, + }); + return buildMutationReceipt({ + status: "applied", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts new file mode 100644 index 0000000..82d83aa --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart8.ts @@ -0,0 +1,370 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart8 = { +_commitBufferedBlockGeneration(this: any, + blockId: string, + text: string, + mutationMode: NonNullable, + contentFormat: AIContentFormat, + sessionId?: string, + options?: { + applyStrategy?: AIApplyStrategy; + insertionOffset?: number; + workingSet?: AIWorkingSetEnvelope | null; + replaceTargetBlock?: boolean; + replaceBlockIds?: readonly string[]; + }, + ): AIMutationReceipt { + let fastApplyFallbackMode: "plain-markdown" | null = null; + if ( + contentFormat === "markdown" && + options?.applyStrategy === "markdown-fast-apply" && + (options?.replaceBlockIds?.length ?? 0) === 0 + ) { + const fastApplyReceipt = this._commitBufferedMarkdownFastApply( + blockId, + text, + mutationMode, + sessionId, + options.workingSet ?? null, + ); + if (fastApplyReceipt) { + return fastApplyReceipt; + } + if (!text.trim().startsWith(`<${MARKDOWN_FAST_APPLY_ROOT_TAG}>`)) { + // Backward compatibility: tolerate plain markdown when the model + // does not honor the fast-apply contract. + fastApplyFallbackMode = "plain-markdown"; + } else { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "Fast apply contract could not be compiled safely.", + ], + }); + } + } + + const normalizedText = + contentFormat === "markdown" + ? normalizeFlowMarkdownOutput(text) + : text; + const scopedReplaceBlockIds = + contentFormat === "markdown" + ? (options?.replaceBlockIds?.filter( + (candidateBlockId, index, allBlockIds) => + allBlockIds.indexOf(candidateBlockId) === index && + this._editor.getBlock(candidateBlockId) != null, + ) ?? []) + : []; + if (contentFormat === "markdown" && scopedReplaceBlockIds.length > 0) { + if (normalizedText.trim().length > 0) { + const verification = this._verifyMarkdownFastApplyResult( + scopedReplaceBlockIds, + normalizedText, + ); + if (!verification.valid) { + return buildMutationReceipt({ + status: "invalid", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + issues: [ + "Scoped markdown replacement could not be verified safely.", + ], + }); + } + } + const ops = this._buildMarkdownScopedReplacementOps( + scopedReplaceBlockIds, + normalizedText, + ); + const scopedReplacementFallback = + this._summarizeFastApplyFallbackOps( + "scoped-replacement", + ops, + scopedReplaceBlockIds.length, + ); + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + this._recordFastApplyDebug({ + executionPath: "scoped-replacement", + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: ops.length > 0 ? "staged_suggestions" : "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + this._recordFastApplyDebug({ + executionPath: "scoped-replacement", + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: ops.length > 0 ? "applied" : "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + if ( + contentFormat === "markdown" && + (mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review") && + this._applySuggestedMarkdownPlaceholderReplacement( + blockId, + normalizedText, + sessionId, + options?.replaceTargetBlock, + options?.replaceBlockIds, + ) + ) { + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + [], + ), + }); + } + return buildMutationReceipt({ + status: "staged_suggestions", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + const ops = + contentFormat === "markdown" + ? this._buildMarkdownBlockGenerationOps( + blockId, + normalizedText, + options?.replaceTargetBlock, + options?.replaceBlockIds, + ) + : this._buildTextBlockGenerationOps( + blockId, + normalizedText, + options?.insertionOffset, + ); + if (ops.length === 0) { + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + ops, + ), + }); + } + return buildMutationReceipt({ + status: "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + ops, + ), + }); + } + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + if (fastApplyFallbackMode) { + this._recordFastApplyDebug({ + executionPath: "plain-markdown", + fallback: this._summarizeFastApplyFallbackOps( + "plain-markdown", + ops, + ), + }); + } + return buildMutationReceipt({ + status: "applied", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } +}; diff --git a/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts new file mode 100644 index 0000000..1502c18 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/aiControllerMethodsPart9.ts @@ -0,0 +1,480 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd, resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt, createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal, resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation, resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot, resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange, buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual, buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpers"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpers"; + +export const aiControllerMethodsPart9 = { +_commitBufferedMarkdownFastApply(this: any, + blockId: string, + text: string, + mutationMode: NonNullable, + sessionId: string | undefined, + workingSet: AIWorkingSetEnvelope | null, + ): AIMutationReceipt | null { + const fastApplyScope = this._resolveMarkdownFastApplyScope( + blockId, + workingSet, + ); + if (!fastApplyScope) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + fallbackReason: "missing-scope", + }); + return null; + } + + const patchPlan = parseMarkdownPatchPlanContract(text); + if (patchPlan) { + const validation = validateDocumentMutationPlanShape( + patchPlan, + this._buildPlanValidationContext( + blockId, + fastApplyScope.blockIds, + ), + ); + if (!validation.valid) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + fallbackReason: "invalid-patch-plan", + verificationFailureReason: validation.issues[0]?.message, + }); + return null; + } + + const execution = buildDocumentMutationPlanExecution( + this._editor, + patchPlan, + ); + if (execution.issues.length > 0) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + fallbackReason: "patch-plan-execution", + verificationFailureReason: execution.issues[0]?.message, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return null; + } + + const verification = this._verifyFlowPatchPlanResult( + patchPlan, + execution.ops, + fastApplyScope.blockIds, + ); + if (!verification.valid) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + fallbackReason: "verification-failed", + verificationFailureReason: verification.reason, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return null; + } + + if (execution.ops.length === 0) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + confidence: patchPlan.confidence?.score, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return buildMutationReceipt({ + status: "noop", + ops: execution.ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(execution.ops, sessionId); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + confidence: patchPlan.confidence?.score, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return buildMutationReceipt({ + status: "staged_suggestions", + ops: execution.ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + this._editor.apply(execution.ops, { + origin: "ai", + undoGroup: true, + }); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + contextChars: fastApplyScope.markdown.length, + diffChars: text.length, + confidence: patchPlan.confidence?.score, + untouchedBlockMutationCount: + verification.untouchedBlockMutationCount, + alignment: execution.metrics?.flowPatchAlignment, + executionPath: "native-fast-apply", + }); + return buildMutationReceipt({ + status: "applied", + ops: execution.ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + const contract = parseMarkdownFastApplyContract(text); + if (!contract) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + fallbackReason: "unparseable-contract", + }); + return null; + } + + const merged = applyMarkdownFastApply({ + originalMarkdown: fastApplyScope.markdown, + contract, + }); + if (!merged.success || !merged.mergedMarkdown) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + confidence: merged.confidence, + fallbackReason: merged.fallbackReason ?? "merge-failed", + verificationFailureReason: merged.issues[0], + }); + return null; + } + + const verification = this._verifyMarkdownFastApplyResult( + fastApplyScope.blockIds, + merged.mergedMarkdown, + ); + if (!verification.valid) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: false, + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + fallbackReason: "verification-failed", + verificationFailureReason: verification.reason, + untouchedBlockMutationCount: 0, + }); + return null; + } + + const ops = this._buildMarkdownScopedReplacementOps( + fastApplyScope.blockIds, + merged.mergedMarkdown, + ); + const scopedReplacementFallback = this._summarizeFastApplyFallbackOps( + "scoped-replacement", + ops, + fastApplyScope.blockIds.length, + ); + if (ops.length === 0) { + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + untouchedBlockMutationCount: 0, + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: "noop", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + if ( + mutationMode === "persistent-suggestions" || + mutationMode === "streaming-suggestions" || + mutationMode === "staged-review" + ) { + this._applySuggestedAIOps(ops, sessionId); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + untouchedBlockMutationCount: 0, + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: "staged_suggestions", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + } + + this._editor.apply(ops, { origin: "ai", undoGroup: true }); + this._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + contextChars: fastApplyScope.markdown.length, + diffChars: merged.diff?.length ?? 0, + confidence: merged.confidence, + untouchedBlockMutationCount: 0, + fallback: scopedReplacementFallback, + }); + return buildMutationReceipt({ + status: "applied", + ops, + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + }, + +_resolveMarkdownFastApplyScope(this: any, + blockId: string, + workingSet: AIWorkingSetEnvelope | null, + ): { markdown: string; blockIds: string[] } | null { + const context = + workingSet?.context && typeof workingSet.context === "object" + ? (workingSet.context as { + markdown?: string | null; + retrievedSpan?: AIWorkingSetRetrievedSpan | null; + markdownWindow?: { + blockIds?: string[]; + } | null; + }) + : null; + const markdown = context?.markdown?.trim() ?? ""; + const blockIds = context?.retrievedSpan?.blockIds?.length + ? context.retrievedSpan.blockIds + : context?.markdownWindow?.blockIds?.length + ? context.markdownWindow.blockIds + : [blockId]; + if (markdown.length === 0 || blockIds.length === 0) { + return null; + } + return { + markdown, + blockIds: [...new Set(blockIds)], + }; + }, + +_buildPlanValidationContext(this: any, + blockId: string, + scopeBlockIds: readonly string[], + ): Parameters[1] { + const knownBlockTypes = this._editor.schema + .allBlocks() + .filter((schema) => + shouldExposeBlockInTooling( + this._editor.documentProfile, + schema, + ), + ) + .map((schema) => schema.type); + const editableTargetBlockIds = scopeBlockIds.filter((targetBlockId) => { + const block = this._editor.getBlock(targetBlockId); + if (!block) { + return false; + } + const schema = this._editor.schema.resolve(block.type); + return shouldExposeBlockInTooling( + this._editor.documentProfile, + schema, + ); + }); + + return { + documentProfile: this._editor.documentProfile, + targetKind: this._resolvePlanValidationTargetKind(blockId), + knownBlockTypes, + allowedTargetBlockIds: [...scopeBlockIds], + editableTargetBlockIds, + }; + } +}; diff --git a/packages/extensions/ai/src/extensionParts/controllerDeps.ts b/packages/extensions/ai/src/extensionParts/controllerDeps.ts new file mode 100644 index 0000000..6439786 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllerDeps.ts @@ -0,0 +1,11 @@ +export { getDocumentToolRuntime } from "@pen/document-ops"; +export { runAgenticLoop } from "../agentic/loop"; +export { getBlockAdapter } from "../runtime/blockAdapters"; +export { buildPlannerPrompt, parseStructuredPlanPreview, parseStructuredPlanResult, resolveExecutionMode } from "../runtime/structuredPlanner"; +export { buildGenerationStructuredPreviewState, buildStructuredPreviewPatchOperations } from "../runtime/structuredPreview"; +export { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +export { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +export { buildStructuralReviewItems } from "../runtime/reviewArtifacts"; +export { routeAIRequest } from "../runtime/router"; +export * from "./extensionHelpers"; +export { buildMutationReceipt } from "../runtime/mutationReceipt"; diff --git a/packages/extensions/ai/src/extensionParts/controllers/aiControllerMethodHost.ts b/packages/extensions/ai/src/extensionParts/controllers/aiControllerMethodHost.ts new file mode 100644 index 0000000..cafe420 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/aiControllerMethodHost.ts @@ -0,0 +1,174 @@ +import type { DocumentOp, Editor, OpOrigin, TextSelection, UndoHistoryMetadataController } from "@pen/types"; +import type { SuggestedAIOperationRunner } from "../../runtime/suggestedOperationRunner"; +import type { ExternalInlineTurnRegistry } from "../../runtime/externalInlineTurnRegistry"; +import type { + AICommandExecutionOptions, + AIControllerState, + AIExtensionConfig, + AIExternalInlineTurnResult, + AIInlineCompletionController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AISession, + AISessionResolution, + GenerationState, + PersistentSuggestion, +} from "../../types"; +import type { + AIInlineHistoryRestoreRequest, + AIInlineShortcutHistoryState, + AIInlineShortcutHistoryWaypoint, + GenerationExecutionContext, + GenerationTarget, +} from "../extensionHelpers"; + +export interface AIControllerMethodHost { + _editor: Editor; + _inlineCompletion: AIInlineCompletionController; + _suggestedOperationRunner: SuggestedAIOperationRunner; + _suggestionPresentation: NonNullable< + AIExtensionConfig["suggestionPresentation"] + >; + _state: AIControllerState; + _suggestions: PersistentSuggestion[]; + _undoHistoryMetadata: UndoHistoryMetadataController | null; + _externalInlineTurnRegistry: ExternalInlineTurnRegistry; + _sessionListeners: Set<() => void>; + _abortController: AbortController | null; + _inlineHistory: AIInlineHistorySnapshot[]; + _inlineHistoryIndex: number; + _documentVersion: number; + _pendingInlineHistoryRestore: AIInlineHistoryRestoreRequest | null; + _isRestoringInlineHistory: boolean; + + _setState(partial: Partial): void; + _updateSession( + sessionId: string, + partial: Partial, + ): void; + _updateSessionTurn( + sessionId: string, + turnId: string, + overrides: Partial, + ): void; + _syncSuggestionsFromDocument(): boolean; + _syncSessionsFromDocument(): boolean; + _syncSuggestionResolutionState(): void; + _emit(): void; + _resolveSessionTurn( + sessionId: string, + turnId: string, + resolution: AISessionResolution, + options?: { finalizeSession?: boolean }, + ): boolean; + getActiveSession(): AISession | null; + startSession(input: { + surface: AISession["surface"]; + target?: "auto" | "selection" | "block" | "document"; + }): AISession; + resolveSessionTurn( + sessionId: string, + turnId: string, + resolution: AISessionResolution, + ): boolean; + clearStreamingReviewPreview(sessionId?: string): void; + cancelActiveGeneration(): void; + handleExternalCommit( + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void; + _executeGeneration( + prompt: string, + target: GenerationTarget, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise; + _runBlockGeneration( + prompt: string, + blockId: string, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise; + _runDocumentGeneration( + prompt: string, + preferredBlockId?: string | null, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise; + _runSelectionGeneration( + prompt: string, + selection: TextSelection, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise; + _setInlineSessionComposerOpen( + sessionId: string, + isOpen: boolean, + options?: { openReason?: "user" | "history" }, + ): void; + _recordInlinePromptSubmissionCheckpoint( + sessionId: string, + prompt: string, + ): void; + _applySuggestedAIOps( + ops: readonly DocumentOp[], + sessionId?: string, + options?: { + generationId?: string; + origin?: OpOrigin; + suggestionIds?: readonly string[]; + turnId?: string; + }, + ): void; + _resolveInlineHistoryTargetIndex( + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): number; + _resolveShortcutInlineHistorySessionId( + currentSnapshot: AIInlineHistorySnapshot | null, + direction: AIInlineHistoryDirection, + ): string | null; + _buildInlineShortcutHistoryWaypoints( + sessionId: string | null, + ): AIInlineShortcutHistoryWaypoint[]; + _resolveCurrentInlineShortcutWaypointIndex( + waypoints: readonly AIInlineShortcutHistoryWaypoint[], + sessionId: string | null, + ): number; + _resolveExternalInlineTurnTransition( + currentSnapshot: AIInlineHistorySnapshot | null, + targetSnapshot: AIInlineHistorySnapshot, + direction: AIInlineHistoryDirection, + ): AIExternalInlineTurnResult | null; + _inlineHistorySnapshotHasTurn( + snapshot: AIInlineHistorySnapshot, + sessionId: string, + turnId: string, + ): boolean; + _applyExternalInlineTurnTransition( + result: AIExternalInlineTurnResult, + direction: AIInlineHistoryDirection, + targetSnapshot: AIInlineHistorySnapshot, + targetIndex: number, + options?: { shortcutOnly?: boolean }, + ): boolean; + _applyInlineHistorySnapshot( + snapshot: AIInlineHistorySnapshot, + options?: { historyTraversal?: boolean }, + ): void; + _resolveShortcutInlineHistoryTraversalSnapshot( + targetSnapshot: AIInlineHistorySnapshot, + sessionId: string | null, + ): AIInlineHistorySnapshot; + _createExternalInlineTurnHistorySessions( + sessionId: string, + turnId: string, + includeTurn: boolean, + ): readonly AISession[]; +} diff --git a/packages/extensions/ai/src/extensionParts/controllers/aiControllerMethodsImports.ts b/packages/extensions/ai/src/extensionParts/controllers/aiControllerMethodsImports.ts new file mode 100644 index 0000000..bbc46e9 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/aiControllerMethodsImports.ts @@ -0,0 +1,251 @@ +export { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +export { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +export type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +export { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +export { runAgenticLoop } from "../../agentic/loop"; +export { defaultAICommands } from "../../commands/defaultCommands"; +export { AICommandRegistry } from "../../commands/registry"; +export { AIInlineHistoryService, AIReviewService } from "../../controllers"; +export { buildAffectedRangeDecorations } from "../../decorations/affectedRange"; +export { buildGenerationZoneDecorations } from "../../decorations/generationZone"; +export { buildTrackChangesDecorations } from "../../decorations/trackChanges"; +export { buildAIReviewPresentationDecorations } from "../../review/reviewPresentation"; +export { getBlockAdapter } from "../../runtime/blockAdapters"; +export type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../../runtime/contracts"; +export { resolveDocumentInsertionAnchor } from "../../runtime/documentInsertionAnchor"; +export { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../../runtime/flowMarkdown"; +export { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../../runtime/markdownFastApply"; +export { parseMarkdownPatchPlanContract } from "../../runtime/markdownPatchPlan"; +export { buildMutationReceipt } from "../../runtime/mutationReceipt"; +export { buildDocumentMutationPlanExecution } from "../../runtime/planExecutor"; +export { validateDocumentMutationPlanShape } from "../../runtime/planValidation"; +export type { StructuralReviewItem } from "../../runtime/reviewArtifacts"; +export { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../../runtime/reviewArtifacts"; +export { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../../runtime/router"; +export { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../../runtime/promptTargeting"; +export { SuggestedAIOperationRunner } from "../../runtime/suggestedOperationRunner"; +export { compileStructuredIntentToPlan } from "../../runtime/structuredIntentCompiler"; +export { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../../runtime/structuredPlanner"; +export { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../../runtime/structuredPreview"; +export { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../../suggestions/acceptReject"; +export { readAllSuggestions } from "../../suggestions/persistent"; +export { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../../suggestions/suggestMode"; +export type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIExternalInlineTurnResult, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamingReviewPreviewInput, + AIStreamingReviewPreviewTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../../types"; +export { + resolveGenerationRequestMode, + isLocalRequestedOperation, + EMPTY_TOOL_RUNTIME, + MAX_STREAM_EVENTS, + AI_UNDO_HISTORY_METADATA_KEY, + resolveOrderedReviewItems, + sortReviewItemsForRemoval, + compareReviewItemRemovalOrder, + resolveActiveBlockId, + readModelId, + supportsStructuredIntent, + createAIStreamEvent, + resolvePromptTarget, + resolveSessionTarget, + resolveSessionAnchor, + resolveSessionSelectionSnapshot, + resolveContextualPromptAnchor, + resolveContextualPromptState, + createInlineHistorySnapshot, + cloneSessionTarget, + cloneInlineHistorySessions, + recreateTextSelection, + resolveSelectionSnapshotBlockRange, + resolveSelectionSnapshotRangeStart, + resolveSelectionSnapshotRangeEnd, + resolveRequestedOperationForSession, + resolveLocalOperationContentFormat, + canUseLocalBlockTextOperation, + canReuseBottomChatSessionOperation, + resolveResolvedEditTargetFromRequestedOperation, + areResolvedEditTargetsEqual, + buildSessionExecutionPrompt, + createRewriteSelectionOperation, + createRewriteSelectionOperationFromResolvedTarget, + createRewriteBlockOperation, + createContinueBlockOperation, + createDocumentTransformOperation, + resolvePreviousGeneratedBlockIds, + shouldReplacePreviousGeneratedBlocks, + resolveReplacementDeleteBlockIds, + createResolvedSelectionEditTarget, + createResolvedScopedEditTarget, + createResolvedEditProposal, + resolveResolvedEditProposal, + resolveSelectionForRequestedOperation, + resolveFullBlockTextSelection, + resolveDocumentBlockRangeSelection, + resolveDocumentTitleSelection, + resolveDocumentParagraphSelection, + parseParagraphReference, + resolveWordOrdinal, + resolveBlockIdForRequestedOperation, + resolveRequestedOperationConflict, + resolveContinueInsertionOffset, + createSelectionSignature, + resolveSessionSelectionTarget, + resolveLiveInlineSelectionTarget, + resolvePendingInlineSelectionTarget, + resolveAcceptedInlineSelectionTarget, + shouldCloseInlineSessionPrompt, + closeInlineSessionPrompt, + createDefaultSessionFastApplyMetrics, + accumulateSessionFastApplyMetrics, + selectionMatchesSnapshot, + resolveSessionSelectionSnapshots, + sessionTargetMatches, + sessionSelectionMatches, + resolveSessionBlockId, + resolveBlockInsertionOffset, + appendUniqueString, + areSuggestionsEqual, + areAIControllerStatesEqual, + areGenerationsEqual, + areSessionsEqual, + areInlineHistorySnapshotsEqual, + didInlineHistoryCheckpointChange, + buildInlineHistoryCheckpoint, + countSettledInlineTurns, + hasStreamingInlineTurns, + resolveInlineShortcutHistoryState, + areInlineShortcutHistoryStatesEqual, + shouldReplaceInlineShortcutWaypointRepresentative, + areEphemeralSuggestionsEqual, + areStringArraysEqual, + areStructuredValuesEqual, + buildSelectionReplacementOps, + sliceInlineDeltasFromOffset, + resolveSelectionText, + shouldReplaceEmptyMarkdownTarget, + shouldTrimLeadingBlankBlockGenerationText, + trimLeadingBlankBlockGenerationText, + isVisuallyEmptyInlineText, +} from "../extensionHelpers"; +export type { + GenerationTarget, + GenerationExecutionContext, + AIInlineHistoryRestoreRequest, + AIInlineShortcutHistoryPhase, + AIInlineShortcutHistoryState, + AIInlineShortcutHistoryWaypoint, + AIStreamEventInput, +} from "../extensionHelpers"; diff --git a/packages/extensions/ai/src/extensionParts/controllers/decorationControllerMethods.ts b/packages/extensions/ai/src/extensionParts/controllers/decorationControllerMethods.ts new file mode 100644 index 0000000..45caafd --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/decorationControllerMethods.ts @@ -0,0 +1,109 @@ +import type { Decoration } from "@pen/types"; +import { buildGenerationZoneDecorations } from "../../decorations/generationZone"; +import { buildAIReviewPresentationDecorations } from "../../review/reviewPresentation"; +import type { + AIStreamingReviewPreviewInput, + AIStreamingReviewPreviewTarget, +} from "../../types"; +import { areStringArraysEqual } from "../extensionHelpers"; +import type { AIControllerMethodHost } from "./aiControllerMethodHost"; + +export const decorationControllerMethods = { + setStreamingReviewPreview( + this: AIControllerMethodHost, + input: AIStreamingReviewPreviewInput, + ): void { + const text = input.text ?? ""; + if (text.length === 0) { + this.clearStreamingReviewPreview(input.sessionId); + return; + } + const previous = this._state.streamingReviewPreview; + const isSamePreview = + previous?.sessionId === input.sessionId && + previous?.turnId === input.turnId && + previous?.target != null && + areStreamingReviewPreviewTargetsEqual( + previous.target, + input.target, + ); + if (isSamePreview && previous.text === text) { + return; + } + this._setState({ + streamingReviewPreview: { + sessionId: input.sessionId, + turnId: input.turnId, + target: input.target, + text, + previousTextLength: isSamePreview ? previous.text.length : 0, + revision: isSamePreview ? previous.revision + 1 : 1, + updatedAt: Date.now(), + }, + }); + }, + + clearStreamingReviewPreview(this: AIControllerMethodHost, sessionId?: string): void { + const previous = this._state.streamingReviewPreview; + if (!previous) { + return; + } + if (sessionId && previous.sessionId !== sessionId) { + return; + } + this._setState({ streamingReviewPreview: null }); + }, + + buildDecorations(this: AIControllerMethodHost): Decoration[] { + const decorations = [ + ...buildAIReviewPresentationDecorations({ + activeGeneration: this._state.activeGeneration, + activeSessionId: this._state.activeSessionId, + editor: this._editor, + sessions: this._state.sessions, + suggestionPresentation: this._suggestionPresentation, + streamingReviewPreview: this._state.streamingReviewPreview, + }), + ...buildGenerationZoneDecorations(this._state.activeGeneration), + ]; + return decorations; + }, +}; + +function areStreamingReviewPreviewTargetsEqual( + left: AIStreamingReviewPreviewTarget, + right: AIStreamingReviewPreviewTarget, +): boolean { + if (left.kind !== right.kind) { + return false; + } + + switch (left.kind) { + case "text-range": + return ( + right.kind === "text-range" && + left.blockId === right.blockId && + left.from === right.from && + left.to === right.to + ); + case "block-range": + return ( + right.kind === "block-range" && + left.start.blockId === right.start.blockId && + left.start.offset === right.start.offset && + left.end.blockId === right.end.blockId && + left.end.offset === right.end.offset && + areStringArraysEqual(left.blockIds, right.blockIds) + ); + case "insertion-point": + return ( + right.kind === "insertion-point" && + left.blockId === right.blockId && + left.offset === right.offset + ); + default: { + const exhaustive: never = left; + return exhaustive; + } + } +} diff --git a/packages/extensions/ai/src/extensionParts/controllers/generationRunnerMethods.ts b/packages/extensions/ai/src/extensionParts/controllers/generationRunnerMethods.ts new file mode 100644 index 0000000..508fd1c --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/generationRunnerMethods.ts @@ -0,0 +1,179 @@ +import type { OpOrigin, TextSelection } from "@pen/types"; +import type { AIControllerMethodHost } from "./aiControllerMethodHost"; +import { getOpOriginType } from "@pen/types"; +import { resolveDocumentInsertionAnchor } from "../../runtime/documentInsertionAnchor"; +import { AI_SESSION_SUGGESTION_ORIGIN } from "../../suggestions/suggestMode"; +import type { GenerationState } from "../../types"; +import type { + GenerationExecutionContext, + GenerationTarget, +} from "../extensionHelpers"; +import { + resolveActiveBlockId, + resolveBlockInsertionOffset, +} from "../extensionHelpers"; + +export const generationRunnerMethods = { + cancelActiveGeneration(this: AIControllerMethodHost): void { + this._abortController?.abort(); + this._abortController = null; + if (this._state.activeGeneration) { + const sessionId = this._state.activeGeneration.sessionId; + this._setState({ + status: "idle", + activeGeneration: { + ...this._state.activeGeneration, + status: "cancelled", + structuredPreview: null, + }, + }); + if (sessionId) { + if (this._state.activeGeneration.turnId) { + this._updateSessionTurn( + sessionId, + this._state.activeGeneration.turnId, + { status: "cancelled" }, + ); + } + this._updateSession(sessionId, { + status: "cancelled", + }); + this.clearStreamingReviewPreview(sessionId); + } + } + this._inlineCompletion.dismissSuggestion(); + }, + + openCommandMenu(this: AIControllerMethodHost): void { + this._setState({ commandMenuOpen: true }); + }, + + closeCommandMenu(this: AIControllerMethodHost): void { + this._setState({ commandMenuOpen: false }); + }, + + setSuggestMode(this: AIControllerMethodHost, enabled: boolean): void { + this._setState({ suggestMode: enabled }); + }, + + handleExternalCommit( + this: AIControllerMethodHost, + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void { + const active = this._state.activeGeneration; + if (!active || active.status !== "streaming") return; + if ( + active.route === "tool-loop" || + active.route === "context-first" || + active.route === "review" + ) { + return; + } + const touched = events.some((event) => { + const originType = getOpOriginType(event.origin); + return ( + originType !== "ai" && + originType !== AI_SESSION_SUGGESTION_ORIGIN && + originType !== "system" && + originType !== "extension" && + event.affectedBlocks.includes(active.blockId) + ); + }); + if (!touched) return; + this.cancelActiveGeneration(); + }, + + async _runBlockGeneration( + this: AIControllerMethodHost, + prompt: string, + blockId: string, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + const block = this._editor.getBlock(blockId); + if (!block) { + throw new Error(`Block "${blockId}" not found`); + } + + const target: GenerationTarget = { + type: "block", + blockId, + offset: resolveBlockInsertionOffset(this._editor, blockId), + }; + return this._executeGeneration( + prompt, + target, + commandId, + maxSteps, + context, + ); + }, + + async _runDocumentGeneration( + this: AIControllerMethodHost, + prompt: string, + preferredBlockId?: string | null, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + const documentTarget = + context?.operation?.target.kind === "document" + ? context.operation.target + : null; + const replaceBlockIds = + documentTarget?.blockIds && documentTarget.blockIds.length > 0 + ? [...documentTarget.blockIds] + : context?.replaceBlockIds; + const insertionAnchor = resolveDocumentInsertionAnchor(this._editor, { + preferredBlockId: + documentTarget?.activeBlockId ?? + documentTarget?.blockIds?.[0] ?? + preferredBlockId ?? + resolveActiveBlockId(this._editor.selection) ?? + null, + }); + if (!insertionAnchor) { + throw new Error( + "Cannot run an AI document prompt without an insertion anchor", + ); + } + + return this._runBlockGeneration( + prompt, + insertionAnchor.blockId, + commandId, + maxSteps, + { + ...context, + replaceTargetBlock: + documentTarget?.placement === "replace-blocks" || + documentTarget?.placement === "replace-empty-block" || + insertionAnchor.strategy === "replace-empty-block" || + (replaceBlockIds?.length ?? 0) > 0, + replaceBlockIds, + }, + ); + }, + + async _runSelectionGeneration( + this: AIControllerMethodHost, + prompt: string, + selection: TextSelection, + commandId?: string, + maxSteps?: number, + context?: GenerationExecutionContext, + ): Promise { + return this._executeGeneration( + prompt, + { type: "selection", selection }, + commandId, + maxSteps, + context, + ); + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/controllers/inlineHistoryNavigation.ts b/packages/extensions/ai/src/extensionParts/controllers/inlineHistoryNavigation.ts new file mode 100644 index 0000000..3439b6e --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/inlineHistoryNavigation.ts @@ -0,0 +1,474 @@ +import type { + AIExternalInlineTurnResult, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, +} from "../../types"; +import { rejectSuggestions } from "../../suggestions/acceptReject"; +import type { AIControllerMethodHost } from "./aiControllerMethodHost"; +import type { AIInlineShortcutHistoryWaypoint } from "../extensionHelpers"; +import { + areInlineHistorySnapshotsEqual, + areInlineShortcutHistoryStatesEqual, + cloneInlineHistorySessions, + resolveInlineShortcutHistoryState, + sessionSelectionMatches, + shouldReplaceInlineShortcutWaypointRepresentative, +} from "../extensionHelpers"; + +export const inlineHistoryNavigation = { + _resolveInlineHistoryTargetIndex( + this: AIControllerMethodHost, + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): number { + const step = direction === "undo" ? -1 : 1; + if (!options?.shortcutOnly) { + return this._inlineHistoryIndex + step; + } + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; + const scopedSessionId = this._resolveShortcutInlineHistorySessionId( + currentSnapshot, + direction, + ); + const waypoints = + this._buildInlineShortcutHistoryWaypoints(scopedSessionId); + if (waypoints.length === 0) { + return -1; + } + const currentWaypointIndex = + this._resolveCurrentInlineShortcutWaypointIndex( + waypoints, + scopedSessionId, + ); + if (currentWaypointIndex < 0) { + return -1; + } + const targetWaypoint = waypoints[currentWaypointIndex + step]; + return targetWaypoint?.representativeIndex ?? -1; + }, + + _resolveShortcutInlineHistorySessionId( + this: AIControllerMethodHost, + currentSnapshot: AIInlineHistorySnapshot | null, + direction: AIInlineHistoryDirection, + ): string | null { + const activeSession = this.getActiveSession(); + if (activeSession?.surface === "inline-edit") { + return activeSession.id; + } + const selection = this._editor.selection; + if ( + currentSnapshot && + selection?.type === "text" && + !selection.isCollapsed + ) { + const matchingSession = [...currentSnapshot.sessions] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + sessionSelectionMatches(session, selection), + ); + if (matchingSession) { + return matchingSession.id; + } + } + if ( + currentSnapshot?.activeSessionId && + currentSnapshot.sessions.some( + (session) => + session.id === currentSnapshot.activeSessionId && + session.surface === "inline-edit", + ) + ) { + return currentSnapshot.activeSessionId; + } + const currentInlineSession = + [...(currentSnapshot?.sessions ?? [])] + .reverse() + .find((session) => session.surface === "inline-edit") ?? null; + if (currentInlineSession) { + return currentInlineSession.id; + } + const step = direction === "undo" ? -1 : 1; + let searchIndex = this._inlineHistoryIndex + step; + while (searchIndex >= 0 && searchIndex < this._inlineHistory.length) { + const searchSnapshot = this._inlineHistory[searchIndex]; + const matchingSelectionSession = + selection?.type === "text" && !selection.isCollapsed + ? ([...(searchSnapshot?.sessions ?? [])] + .reverse() + .find( + (session) => + session.surface === "inline-edit" && + sessionSelectionMatches(session, selection), + ) ?? null) + : null; + if (matchingSelectionSession) { + return matchingSelectionSession.id; + } + const searchInlineSession = + [...(searchSnapshot?.sessions ?? [])] + .reverse() + .find((session) => session.surface === "inline-edit") ?? + null; + if (searchInlineSession) { + return searchInlineSession.id; + } + searchIndex += step; + } + return null; + }, + + _buildInlineShortcutHistoryWaypoints( + this: AIControllerMethodHost, + sessionId: string | null, + ): AIInlineShortcutHistoryWaypoint[] { + const waypoints: AIInlineShortcutHistoryWaypoint[] = []; + for (let index = 0; index < this._inlineHistory.length; index += 1) { + const snapshot = this._inlineHistory[index]; + if (!snapshot || snapshot.kind === "ui-local") { + continue; + } + const state = resolveInlineShortcutHistoryState( + snapshot, + sessionId, + ); + if (!state) { + continue; + } + const previousWaypoint = waypoints[waypoints.length - 1] ?? null; + if ( + previousWaypoint && + areInlineShortcutHistoryStatesEqual( + previousWaypoint.state, + state, + ) + ) { + previousWaypoint.endIndex = index; + if ( + shouldReplaceInlineShortcutWaypointRepresentative( + previousWaypoint.state, + this._inlineHistory[ + previousWaypoint.representativeIndex + ] ?? null, + snapshot, + ) + ) { + previousWaypoint.representativeIndex = index; + } + continue; + } + waypoints.push({ + startIndex: index, + endIndex: index, + representativeIndex: index, + state, + }); + } + return waypoints; + }, + + _resolveCurrentInlineShortcutWaypointIndex( + this: AIControllerMethodHost, + waypoints: readonly AIInlineShortcutHistoryWaypoint[], + sessionId: string | null, + ): number { + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; + const currentState = currentSnapshot + ? resolveInlineShortcutHistoryState(currentSnapshot, sessionId) + : null; + if (currentState) { + const currentIndex = waypoints.findIndex( + (waypoint) => + this._inlineHistoryIndex >= waypoint.startIndex && + this._inlineHistoryIndex <= waypoint.endIndex && + areInlineShortcutHistoryStatesEqual( + waypoint.state, + currentState, + ), + ); + if (currentIndex >= 0) { + return currentIndex; + } + const matchingIndex = waypoints.findIndex((waypoint) => + areInlineShortcutHistoryStatesEqual( + waypoint.state, + currentState, + ), + ); + if (matchingIndex >= 0) { + return matchingIndex; + } + } + for (let index = waypoints.length - 1; index >= 0; index -= 1) { + if ( + waypoints[index]!.representativeIndex <= + this._inlineHistoryIndex + ) { + return index; + } + } + return waypoints.length > 0 ? 0 : -1; + }, + + _canHandleInlineHistoryShortcut( + this: AIControllerMethodHost, + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): boolean { + const targetIndex = this._resolveInlineHistoryTargetIndex( + direction, + options, + ); + const targetSnapshot = this._inlineHistory[targetIndex]; + if (!targetSnapshot) { + return false; + } + if (targetSnapshot.kind !== "ui-local") { + return true; + } + return direction === "undo" + ? !this._editor.undoManager.canUndo() + : !this._editor.undoManager.canRedo(); + }, + + _resolveExternalInlineTurnTransition( + this: AIControllerMethodHost, + currentSnapshot: AIInlineHistorySnapshot | null, + targetSnapshot: AIInlineHistorySnapshot, + direction: AIInlineHistoryDirection, + ): + | (AIExternalInlineTurnResult & { + beforeSnapshotId?: string; + afterSnapshotId?: string; + }) + | null { + if (!currentSnapshot) { + return null; + } + const results = this._externalInlineTurnRegistry.resolveTransition( + currentSnapshot, + targetSnapshot, + direction, + (snapshot, sessionId, turnId) => + this._inlineHistorySnapshotHasTurn(snapshot, sessionId, turnId), + ); + return results; + }, + + _inlineHistorySnapshotHasTurn( + this: AIControllerMethodHost, + snapshot: AIInlineHistorySnapshot, + sessionId: string, + turnId: string, + ): boolean { + const session = + snapshot.sessions.find( + (item) => item.id === sessionId && item.surface === "inline-edit", + ) ?? null; + return session?.turns.some((turn) => turn.id === turnId) === true; + }, + + _applyExternalInlineTurnTransition( + this: AIControllerMethodHost, + result: AIExternalInlineTurnResult, + direction: AIInlineHistoryDirection, + targetSnapshot: AIInlineHistorySnapshot, + targetIndex: number, + _options?: { shortcutOnly?: boolean }, + ): boolean { + if (direction === "undo") { + const didReject = rejectSuggestions( + this._editor, + result.suggestionIds, + { + origin: "system", + }, + ); + if (!didReject) { + return false; + } + } else { + this._applySuggestedAIOps([...result.operations], result.sessionId, { + generationId: result.historyId, + origin: "system", + suggestionIds: result.suggestionIds, + turnId: result.turnId, + }); + } + this._syncSuggestionsFromDocument(); + this._applyInlineHistorySnapshot(targetSnapshot, { + historyTraversal: true, + }); + this._inlineHistoryIndex = targetIndex; + return true; + }, + + _navigateInlineHistory( + this: AIControllerMethodHost, + direction: AIInlineHistoryDirection, + options?: { shortcutOnly?: boolean }, + ): boolean { + const targetIndex = this._resolveInlineHistoryTargetIndex( + direction, + options, + ); + const targetSnapshot = this._inlineHistory[targetIndex]; + if (!targetSnapshot) { + return false; + } + const currentSnapshot = + this._inlineHistory[this._inlineHistoryIndex] ?? null; + const shortcutSessionId = options?.shortcutOnly + ? this._resolveShortcutInlineHistorySessionId( + currentSnapshot, + direction, + ) + : null; + const externalTransition = this._resolveExternalInlineTurnTransition( + currentSnapshot, + targetSnapshot, + direction, + ); + if (externalTransition) { + return this._applyExternalInlineTurnTransition( + externalTransition, + direction, + targetSnapshot, + targetIndex, + options, + ); + } + if (targetSnapshot.kind === "ui-local") { + this._applyInlineHistorySnapshot(targetSnapshot, { + historyTraversal: true, + }); + this._inlineHistoryIndex = targetIndex; + return true; + } + if ( + currentSnapshot && + currentSnapshot.documentVersion !== targetSnapshot.documentVersion + ) { + const targetState = resolveInlineShortcutHistoryState( + targetSnapshot, + shortcutSessionId ?? + targetSnapshot.sessionId ?? + targetSnapshot.activeSessionId ?? + null, + ); + this._pendingInlineHistoryRestore = { + direction, + targetSnapshotId: targetSnapshot.id, + targetDocumentVersion: targetSnapshot.documentVersion, + shortcutOnly: options?.shortcutOnly === true, + sessionId: shortcutSessionId, + targetState, + }; + const restored = + direction === "undo" + ? this._editor.undoManager.undo() + : this._editor.undoManager.redo(); + if (!restored) { + this._pendingInlineHistoryRestore = null; + const externalTransition = + this._resolveExternalInlineTurnTransition( + currentSnapshot, + targetSnapshot, + direction, + ); + if (externalTransition) { + return this._applyExternalInlineTurnTransition( + externalTransition, + direction, + targetSnapshot, + targetIndex, + options, + ); + } + } + return restored; + } + const resolvedTargetSnapshot = options?.shortcutOnly + ? this._resolveShortcutInlineHistoryTraversalSnapshot( + targetSnapshot, + shortcutSessionId, + ) + : targetSnapshot; + this._applyInlineHistorySnapshot(resolvedTargetSnapshot, { + historyTraversal: true, + }); + this._inlineHistoryIndex = targetIndex; + return true; + }, + + _applyInlineHistorySnapshot( + this: AIControllerMethodHost, + snapshot: AIInlineHistorySnapshot, + options?: { historyTraversal?: boolean }, + ): void { + this._isRestoringInlineHistory = true; + try { + const restoredSessions = cloneInlineHistorySessions( + this._editor, + snapshot.sessions, + ).map((session) => { + if ( + !options?.historyTraversal || + !session.contextualPrompt?.composer.isOpen + ) { + return session; + } + return { + ...session, + contextualPrompt: { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + openReason: "history" as const, + }, + }, + }; + }); + this._setState({ + status: "idle", + activeGeneration: null, + streamingReviewPreview: null, + sessions: restoredSessions, + activeSessionId: snapshot.activeSessionId, + }); + } finally { + this._isRestoringInlineHistory = false; + } + }, + + _restoreInlineHistorySnapshotFromUndo( + this: AIControllerMethodHost, + snapshot: AIInlineHistorySnapshot, + ): void { + const targetIndex = this._inlineHistory.findIndex( + (item) => item.id === snapshot.id, + ); + if (targetIndex >= 0) { + this._inlineHistoryIndex = targetIndex; + this._applyInlineHistorySnapshot( + this._inlineHistory[targetIndex]!, + { + historyTraversal: true, + }, + ); + return; + } + this._applyInlineHistorySnapshot(snapshot, { historyTraversal: true }); + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + nextHistory.push(snapshot); + this._inlineHistory = nextHistory; + this._inlineHistoryIndex = nextHistory.length - 1; + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/controllers/inlineHistoryRecording.ts b/packages/extensions/ai/src/extensionParts/controllers/inlineHistoryRecording.ts new file mode 100644 index 0000000..f388962 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/inlineHistoryRecording.ts @@ -0,0 +1,324 @@ +import type { DocumentOp } from "@pen/types"; +import type { + AIControllerState, + AIInlineHistorySnapshot, + AISession, +} from "../../types"; +import type { AIControllerMethodHost } from "./aiControllerMethodHost"; +import { canRegisterExternalInlineTurn } from "../../runtime/externalInlineTurnRegistry"; +import { + AI_UNDO_HISTORY_METADATA_KEY, + areInlineHistorySnapshotsEqual, + createInlineHistorySnapshot, + didInlineHistoryCheckpointChange, +} from "../extensionHelpers"; + +export const inlineHistoryRecording = { + registerExternalInlineTurnResult( + this: AIControllerMethodHost, + input: { + sessionId: string; + turnId: string; + historyId: string; + operations: readonly DocumentOp[]; + suggestionIds: readonly string[]; + }, + ): boolean { + if ( + !canRegisterExternalInlineTurn( + input, + this._externalInlineTurnRegistry, + ) + ) { + return false; + } + + const session = + this._state.sessions.find((item) => item.id === input.sessionId) ?? + null; + const turn = + session?.turns.find((item) => item.id === input.turnId) ?? null; + if (!session || !turn) { + return false; + } + + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + const fullCurrentSnapshot = createInlineHistorySnapshot( + this._editor, + this._state.sessions, + this._state.activeSessionId ?? input.sessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + const currentSnapshot = nextHistory[nextHistory.length - 1] ?? null; + const currentSnapshotIsFull = + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, fullCurrentSnapshot); + const retainedCurrentSnapshot = currentSnapshotIsFull + ? currentSnapshot + : null; + const workingHistory = retainedCurrentSnapshot + ? nextHistory.slice(0, -1) + : nextHistory; + const beforeSnapshot = createInlineHistorySnapshot( + this._editor, + this._createExternalInlineTurnHistorySessions( + input.sessionId, + input.turnId, + false, + ), + input.sessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + const beforePreviousSnapshot = + workingHistory[workingHistory.length - 1] ?? null; + if ( + !beforePreviousSnapshot || + beforePreviousSnapshot.kind === "ui-local" || + !areInlineHistorySnapshotsEqual( + beforePreviousSnapshot, + beforeSnapshot, + ) + ) { + workingHistory.push(beforeSnapshot); + } + + const afterSnapshot = createInlineHistorySnapshot( + this._editor, + this._createExternalInlineTurnHistorySessions( + input.sessionId, + input.turnId, + true, + ), + input.sessionId, + this._documentVersion, + { kind: "document-coupled" }, + ); + const lastSnapshot = workingHistory[workingHistory.length - 1] ?? null; + const registeredAfterSnapshot = + lastSnapshot && + areInlineHistorySnapshotsEqual(lastSnapshot, afterSnapshot) + ? lastSnapshot + : afterSnapshot; + if (registeredAfterSnapshot === afterSnapshot) { + workingHistory.push(afterSnapshot); + } + if ( + retainedCurrentSnapshot && + !areInlineHistorySnapshotsEqual( + workingHistory[workingHistory.length - 1]!, + retainedCurrentSnapshot, + ) + ) { + workingHistory.push(retainedCurrentSnapshot); + } + + this._inlineHistory = workingHistory; + this._inlineHistoryIndex = workingHistory.length - 1; + this._externalInlineTurnRegistry.set(input.historyId, { + ...input, + operations: [...input.operations], + suggestionIds: [...input.suggestionIds], + beforeSnapshotId: beforeSnapshot.id, + afterSnapshotId: registeredAfterSnapshot.id, + }); + return true; + }, + + _createExternalInlineTurnHistorySessions( + this: AIControllerMethodHost, + sessionId: string, + turnId: string, + includeTurn: boolean, + ): readonly AISession[] { + return this._state.sessions.map((session) => { + if (session.id !== sessionId || session.surface !== "inline-edit") { + return session; + } + const turn = + session.turns.find((item) => item.id === turnId) ?? null; + if (!turn) { + return session; + } + const turnIndex = session.turns.findIndex( + (item) => item.id === turnId, + ); + const nextTurns = session.turns + .slice(0, includeTurn ? turnIndex + 1 : turnIndex) + .map((item) => { + const hasExternalResult = + item.id === turnId || + this._externalInlineTurnRegistry.turnHasExternalResult( + sessionId, + item.id, + ); + if (!hasExternalResult || item.status !== "cancelled") { + return item; + } + return { + ...item, + status: "review" as const, + }; + }); + const nextPendingSuggestionIds = [ + ...new Set(nextTurns.flatMap((item) => item.suggestionIds)), + ]; + const nextPendingReviewItemIds = [ + ...new Set(nextTurns.flatMap((item) => item.reviewItemIds)), + ]; + const previousTurn = nextTurns[nextTurns.length - 1] ?? null; + return { + ...session, + status: previousTurn ? session.status : "idle", + turns: nextTurns, + activeTurnId: previousTurn?.id, + pendingSuggestionIds: nextPendingSuggestionIds, + pendingReviewItemIds: nextPendingReviewItemIds, + contextualPrompt: session.contextualPrompt + ? { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + draftPrompt: turn.prompt, + isOpen: true, + isSubmitting: false, + }, + } + : session.contextualPrompt, + }; + }); + }, + + _recordInlineHistorySnapshot( + this: AIControllerMethodHost, + previousState: AIControllerState, + nextState: AIControllerState, + ): void { + if (!didInlineHistoryCheckpointChange(previousState, nextState)) { + return; + } + if ( + previousState.sessions === nextState.sessions && + previousState.activeSessionId === nextState.activeSessionId + ) { + return; + } + const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + if (nextHistory.length === 0) { + const baselineSnapshot = createInlineHistorySnapshot( + this._editor, + previousState.sessions, + previousState.activeSessionId ?? null, + this._documentVersion, + ); + nextHistory.push(baselineSnapshot); + } + const previousSnapshot = + nextHistory[nextHistory.length - 1] ?? currentSnapshot ?? null; + const snapshot = createInlineHistorySnapshot( + this._editor, + nextState.sessions, + nextState.activeSessionId ?? null, + this._documentVersion, + { + kind: + previousSnapshot?.documentVersion === this._documentVersion + ? "ui-local" + : "document-coupled", + }, + ); + if ( + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) + ) { + return; + } + const currentUndoMetadata = + this._undoHistoryMetadata?.getCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + ) ?? null; + const shouldPersistUndoSnapshot = + previousSnapshot != null && + (snapshot.kind === "document-coupled" || + currentUndoMetadata?.after?.documentVersion === + this._documentVersion); + if (shouldPersistUndoSnapshot && previousSnapshot) { + this._undoHistoryMetadata?.setCurrentEntryMetadata( + AI_UNDO_HISTORY_METADATA_KEY, + { + before: currentUndoMetadata?.before ?? previousSnapshot, + after: snapshot, + }, + ); + } + nextHistory.push(snapshot); + this._inlineHistory = nextHistory; + this._inlineHistoryIndex = nextHistory.length - 1; + }, + + _recordInlinePromptSubmissionCheckpoint( + this: AIControllerMethodHost, + sessionId: string, + prompt: string, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if ( + !session || + session.surface !== "inline-edit" || + !session.contextualPrompt + ) { + return; + } + const checkpointState: AIControllerState = { + ...this._state, + activeSessionId: sessionId, + sessions: this._state.sessions.map((item) => + item.id !== sessionId + ? item + : { + ...item, + contextualPrompt: { + ...item.contextualPrompt!, + composer: { + ...item.contextualPrompt!.composer, + draftPrompt: prompt, + isOpen: true, + isSubmitting: false, + }, + }, + }, + ), + }; + const snapshot = createInlineHistorySnapshot( + this._editor, + checkpointState.sessions, + checkpointState.activeSessionId ?? null, + this._documentVersion, + { kind: "ui-local" }, + ); + const currentSnapshot = this._inlineHistory[this._inlineHistoryIndex]; + if ( + currentSnapshot && + areInlineHistorySnapshotsEqual(currentSnapshot, snapshot) + ) { + return; + } + const nextHistory = this._inlineHistory.slice( + 0, + this._inlineHistoryIndex + 1, + ); + nextHistory.push(snapshot); + this._inlineHistory = nextHistory; + this._inlineHistoryIndex = nextHistory.length - 1; + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/controllers/sessionControllerMethods.ts b/packages/extensions/ai/src/extensionParts/controllers/sessionControllerMethods.ts new file mode 100644 index 0000000..db9045c --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/sessionControllerMethods.ts @@ -0,0 +1,308 @@ +import type { + AICommandExecutionOptions, + AIContextualPromptRect, + AISession, + AISessionResolution, + AISurface, + GenerationState, +} from "../../types"; +import type { AIControllerMethodHost } from "./aiControllerMethodHost"; +import { + createDefaultSessionFastApplyMetrics, + resolveBlockIdForRequestedOperation, + resolveContextualPromptAnchor, + resolveContextualPromptState, + resolvePreviousGeneratedBlockIds, + resolveRequestedOperationForSession, + resolveSelectionForRequestedOperation, + resolveSessionAnchor, + resolveSessionTarget, + sessionTargetMatches, + shouldReplacePreviousGeneratedBlocks, +} from "../extensionHelpers"; + +export const sessionControllerMethods = { + getSessions(this: AIControllerMethodHost): readonly AISession[] { + return this._state.sessions; + }, + + getActiveSession(this: AIControllerMethodHost): AISession | null { + const activeSessionId = this._state.activeSessionId; + if (!activeSessionId) { + return null; + } + return ( + this._state.sessions.find( + (session) => session.id === activeSessionId, + ) ?? null + ); + }, + + subscribeSessions(this: AIControllerMethodHost, listener: () => void): () => void { + this._sessionListeners.add(listener); + return () => this._sessionListeners.delete(listener); + }, + + startSession( + this: AIControllerMethodHost, + input: { + surface: AISurface; + target?: "auto" | "selection" | "block" | "document"; + }, + ): AISession { + const now = Date.now(); + const target = resolveSessionTarget(this._editor, input.target); + const session: AISession = { + id: crypto.randomUUID(), + surface: input.surface, + status: "idle", + target, + contextualPrompt: + input.surface === "inline-edit" + ? resolveContextualPromptState(target) + : undefined, + turns: [], + activeTurnId: undefined, + promptHistory: [], + generationIds: [], + pendingSuggestionIds: [], + pendingReviewItemIds: [], + createdAt: now, + updatedAt: now, + metrics: { + streamEventCount: 0, + patchCount: 0, + fastApply: createDefaultSessionFastApplyMetrics(), + }, + anchor: resolveSessionAnchor(this._editor.selection), + }; + this._setState({ + sessions: [...this._state.sessions, session], + activeSessionId: session.id, + }); + return session; + }, + + openContextualPrompt( + this: AIControllerMethodHost, + input?: { + surface?: Extract; + target?: "auto" | "selection" | "block" | "document"; + }, + ): AISession | null { + const surface = input?.surface ?? "inline-edit"; + const target = resolveSessionTarget( + this._editor, + input?.target ?? "selection", + ); + if (surface === "inline-edit" && target.kind === "document") { + return null; + } + const activeSession = this._state.sessions.find( + (session) => + session.id === this._state.activeSessionId && + session.surface === surface && + session.status !== "cancelled", + ); + if ( + activeSession && + activeSession.status !== "complete" && + sessionTargetMatches(activeSession, target) + ) { + this._updateSession(activeSession.id, { + target, + anchor: resolveSessionAnchor(this._editor.selection), + contextualPrompt: { + ...(activeSession.contextualPrompt ?? + resolveContextualPromptState(target)), + anchor: resolveContextualPromptAnchor(target), + composer: { + ...(activeSession.contextualPrompt?.composer ?? { + draftPrompt: "", + isSubmitting: false, + canSubmitFollowUp: true, + openReason: "user", + }), + isOpen: true, + openReason: "user", + }, + }, + }); + return this.getActiveSession(); + } + if (activeSession?.surface === "inline-edit") { + this._setInlineSessionComposerOpen(activeSession.id, false); + } + const nextSession = this.startSession({ + surface, + target: input?.target ?? "selection", + }); + const anchorKind = nextSession.contextualPrompt?.anchor.kind; + return anchorKind === "text-range" || anchorKind === "block" + ? nextSession + : null; + }, + + updateContextualPromptDraft( + this: AIControllerMethodHost, + sessionId: string, + draftPrompt: string, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session?.contextualPrompt) { + return; + } + this._updateSession(sessionId, { + contextualPrompt: { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + draftPrompt, + }, + }, + }); + }, + + setContextualPromptAnchorRect( + this: AIControllerMethodHost, + sessionId: string, + rect: AIContextualPromptRect | null, + ): void { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session?.contextualPrompt) { + return; + } + this._updateSession(sessionId, { + contextualPrompt: { + ...session.contextualPrompt, + anchor: { + ...session.contextualPrompt.anchor, + lastResolvedRect: rect, + }, + }, + }); + }, + + resolveSessionTurn( + this: AIControllerMethodHost, + sessionId: string, + turnId: string, + resolution: AISessionResolution, + ): boolean { + return this._resolveSessionTurn(sessionId, turnId, resolution); + }, + + acceptSessionTurn(this: AIControllerMethodHost, sessionId: string, turnId: string): boolean { + return this.resolveSessionTurn(sessionId, turnId, "accept"); + }, + + rejectSessionTurn(this: AIControllerMethodHost, sessionId: string, turnId: string): boolean { + return this.resolveSessionTurn(sessionId, turnId, "reject"); + }, + + runSessionPrompt( + this: AIControllerMethodHost, + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): Promise { + const session = this._state.sessions.find( + (item) => item.id === sessionId, + ); + if (!session) { + return Promise.reject( + new Error(`Unknown AI session "${sessionId}"`), + ); + } + this._recordInlinePromptSubmissionCheckpoint(sessionId, prompt); + + const operation = + options?.operation ?? + resolveRequestedOperationForSession( + this._editor, + session, + prompt, + options, + this._documentVersion, + ); + if (operation.kind === "rewrite-selection") { + const selection = resolveSelectionForRequestedOperation( + this._editor, + operation, + ); + if (!selection) { + return Promise.reject( + new Error( + "Cannot run a session prompt without a valid text selection", + ), + ); + } + return this._runSelectionGeneration( + prompt, + selection, + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + }, + ); + } + if (operation.kind === "document-transform") { + const targetBlockIds = + operation.target.kind === "document" && + (operation.target.blockIds?.length ?? 0) > 0 + ? [...(operation.target.blockIds ?? [])] + : undefined; + const replacePreviousGeneratedBlocks = + shouldReplacePreviousGeneratedBlocks(session, prompt); + return this._runDocumentGeneration( + prompt, + options?.blockId ?? + (operation.target.kind === "document" + ? operation.target.activeBlockId + : null), + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + replaceBlockIds: + targetBlockIds ?? + (replacePreviousGeneratedBlocks + ? resolvePreviousGeneratedBlockIds(session) + : undefined), + }, + ); + } + const blockId = + options?.blockId ?? + resolveBlockIdForRequestedOperation(operation) ?? + this._editor.lastBlock()?.id ?? + this._editor.firstBlock()?.id; + if (!blockId) { + return Promise.reject( + new Error( + "Cannot run an AI session prompt without a target block", + ), + ); + } + return this._runBlockGeneration( + prompt, + blockId, + undefined, + options?.maxSteps, + { + sessionId, + surface: session.surface, + operation, + }, + ); + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/controllers/suggestionControllerMethods.ts b/packages/extensions/ai/src/extensionParts/controllers/suggestionControllerMethods.ts new file mode 100644 index 0000000..530b530 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/controllers/suggestionControllerMethods.ts @@ -0,0 +1,119 @@ +import type { OpOrigin } from "@pen/types"; +import type { AIInlineCompletionController } from "../../types"; +import type { AIControllerMethodHost } from "./aiControllerMethodHost"; +import { + acceptAllSuggestions, + acceptSuggestion, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../../suggestions/acceptReject"; +import { readAllSuggestions } from "../../suggestions/persistent"; +import { AI_SESSION_SUGGESTION_ORIGIN } from "../../suggestions/suggestMode"; +import { areSuggestionsEqual } from "../extensionHelpers"; + +export const suggestionControllerMethods = { + showEphemeralSuggestion( + this: AIControllerMethodHost, + suggestion: Parameters< + AIInlineCompletionController["showSuggestion"] + >[0], + ): void { + this._inlineCompletion.showSuggestion(suggestion); + }, + + dismissEphemeralSuggestion(this: AIControllerMethodHost): void { + this._inlineCompletion.dismissSuggestion(); + }, + + acceptEphemeralSuggestion(this: AIControllerMethodHost): void { + this._inlineCompletion.acceptSuggestion(); + }, + + getSuggestions(this: AIControllerMethodHost) { + return this._suggestions; + }, + + handleDocumentChange( + this: AIControllerMethodHost, + events: readonly { + origin: OpOrigin; + affectedBlocks: readonly string[]; + }[], + ): void { + if (events.length > 0) { + this._documentVersion += 1; + } + const previousState = this._state; + const suggestionsChanged = this._syncSuggestionsFromDocument(); + const sessionsChanged = this._syncSessionsFromDocument(); + this.handleExternalCommit(events); + if (this._state === previousState) { + this._editor.requestDecorationUpdate(); + if (suggestionsChanged || sessionsChanged) { + this._emit(); + } + } + }, + + _syncSuggestionResolutionState(this: AIControllerMethodHost): void { + const suggestionsChanged = this._syncSuggestionsFromDocument(); + const sessionsChanged = this._syncSessionsFromDocument(); + if (!suggestionsChanged && !sessionsChanged) { + return; + } + this._editor.requestDecorationUpdate(); + this._emit(); + }, + + acceptSuggestion(this: AIControllerMethodHost, id: string): boolean { + const accepted = acceptSuggestion(this._editor, id); + if (accepted) { + this._syncSuggestionResolutionState(); + } + return accepted; + }, + + rejectSuggestion(this: AIControllerMethodHost, id: string): boolean { + const rejected = rejectSuggestion(this._editor, id); + if (rejected) { + this._syncSuggestionResolutionState(); + } + return rejected; + }, + + _rejectPreviewSuggestions( + this: AIControllerMethodHost, + suggestionIds: readonly string[], + ): void { + if (suggestionIds.length === 0) { + return; + } + const rejected = rejectSuggestions(this._editor, suggestionIds, { + origin: AI_SESSION_SUGGESTION_ORIGIN, + undoGroupId: this._state.activeGeneration?.undoGroupId, + }); + if (rejected) { + this._syncSuggestionResolutionState(); + } + }, + + acceptAllSuggestions(this: AIControllerMethodHost): void { + acceptAllSuggestions(this._editor); + this._syncSuggestionResolutionState(); + }, + + rejectAllSuggestions(this: AIControllerMethodHost): void { + rejectAllSuggestions(this._editor); + this._syncSuggestionResolutionState(); + }, + + _syncSuggestionsFromDocument(this: AIControllerMethodHost): boolean { + const nextSuggestions = readAllSuggestions(this._editor); + if (areSuggestionsEqual(this._suggestions, nextSuggestions)) { + return false; + } + this._suggestions = nextSuggestions; + return true; + }, +}; diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpers.ts b/packages/extensions/ai/src/extensionParts/extensionHelpers.ts new file mode 100644 index 0000000..1afc6c5 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpers.ts @@ -0,0 +1,9 @@ +export * from "./extensionHelpersPart1"; +export * from "./extensionHelpersPart2"; +export * from "./extensionHelpersPart3"; +export * from "./extensionHelpersPart4"; +export * from "./extensionHelpersPart5"; +export * from "./extensionHelpersPart6"; +export * from "./extensionHelpersPart7"; +export * from "./extensionHelpersPart8"; +export * from "./extensionHelpersPart9"; diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts new file mode 100644 index 0000000..2c02878 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart1.ts @@ -0,0 +1,477 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export type GenerationTarget = + | { + type: "block"; + blockId: string; + offset: number; + } + | { + type: "selection"; + selection: TextSelection; + }; + +export interface GenerationExecutionContext { + sessionId?: string; + surface?: AISurface; + targetType?: GenerationTarget["type"]; + operation?: AIRequestedOperation | null; + replaceTargetBlock?: boolean; + replaceBlockIds?: string[]; +} + +export function resolveGenerationRequestMode( + context?: GenerationExecutionContext, +): string | undefined { + if (context?.operation?.kind === "rewrite-selection") { + if (context.surface === "inline-edit") { + return "inline-edit"; + } + if (context.surface === "bottom-chat") { + return "selection-fast"; + } + } + if (context?.targetType === "selection") { + if (context.surface === "inline-edit") { + return "inline-edit"; + } + if (context.surface === "bottom-chat") { + return "selection-fast"; + } + } + if (context?.surface === "inline-edit") { + return "inline-edit"; + } + if (context?.surface === "bottom-chat") { + return "bottom-chat"; + } + return undefined; +} + +export function isLocalRequestedOperation( + operation: AIRequestedOperation | null | undefined, +): operation is AIRequestedOperation { + return ( + operation?.kind === "rewrite-selection" || + operation?.kind === "rewrite-block" || + operation?.kind === "continue-block" || + (operation?.kind === "document-transform" && + operation.target.kind === "document" && + (operation.target.transform === "rewrite" || + operation.target.transform === "remove" || + operation.target.placement === "replace-blocks")) + ); +} + +export const EMPTY_TOOL_RUNTIME: ToolRuntime = { + registerTool(_def: ToolDefinition): void {}, + unregisterTool(_name: string): void {}, + listTools(): readonly ToolDefinition[] { + return []; + }, + getTool(): ToolDefinition | null { + return null; + }, + async executeTool(name: string): Promise { + throw new Error(`Unknown tool: "${name}"`); + }, +}; + +export const MAX_STREAM_EVENTS = 200; + +export const AI_UNDO_HISTORY_METADATA_KEY = "ai:inline-session-history"; + +export interface AIInlineHistoryRestoreRequest { + direction: AIInlineHistoryDirection; + targetSnapshotId: string; + targetDocumentVersion: number; + shortcutOnly?: boolean; + sessionId?: string | null; + targetState?: AIInlineShortcutHistoryState | null; +} + +export type AIInlineShortcutHistoryPhase = "none" | "review" | "resolved"; + +export interface AIInlineShortcutHistoryState { + sessionId: string | null; + phase: AIInlineShortcutHistoryPhase; + turnCount: number; + turnId: string | null; + resolution?: "accepted" | "rejected"; +} + +export interface AIInlineShortcutHistoryWaypoint { + startIndex: number; + endIndex: number; + representativeIndex: number; + state: AIInlineShortcutHistoryState; +} + +export function resolveOrderedReviewItems( + reviewItems: readonly StructuralReviewItem[], + ids: readonly string[], +): StructuralReviewItem[] { + const remainingIds = new Set(ids); + const orderedReviewItems: StructuralReviewItem[] = []; + for (const reviewItem of reviewItems) { + if (!remainingIds.has(reviewItem.id)) { + continue; + } + orderedReviewItems.push(reviewItem); + remainingIds.delete(reviewItem.id); + } + return orderedReviewItems; +} + +export function sortReviewItemsForRemoval( + reviewItems: readonly StructuralReviewItem[], +): StructuralReviewItem[] { + return [...reviewItems].sort(compareReviewItemRemovalOrder); +} + +export function compareReviewItemRemovalOrder( + left: StructuralReviewItem, + right: StructuralReviewItem, +): number { + const maxPathLength = Math.max( + left.bundlePath.length, + right.bundlePath.length, + ); + for (let index = 0; index < maxPathLength; index += 1) { + const leftPart = left.bundlePath[index] ?? -1; + const rightPart = right.bundlePath[index] ?? -1; + if (leftPart !== rightPart) { + return rightPart - leftPart; + } + } + + const leftStepIndex = left.stepIndex ?? -1; + const rightStepIndex = right.stepIndex ?? -1; + return rightStepIndex - leftStepIndex; +} + +export function resolveActiveBlockId(selection: SelectionState): string | null { + if (!selection) return null; + if (selection.type === "text") return selection.focus.blockId; + if (selection.type === "block") return selection.blockIds[0] ?? null; + if (selection.type === "cell") return selection.blockId; + return null; +} + +export function readModelId(model: ModelAdapter | undefined): string | undefined { + if (!model || typeof model !== "object") return undefined; + const candidate = model as ModelAdapter & { + name?: string; + modelId?: string; + }; + return candidate.modelId ?? candidate.name; +} + +export function supportsStructuredIntent(model: ModelAdapter | undefined): boolean { + return model?.capabilities?.structuredIntent === true; +} + +export type AIStreamEventInput = + | { + type: "generation-start"; + prompt: string; + target: GenerationState["target"]; + } + | { + type: "status"; + status: AIControllerState["status"]; + } + | { + type: "text-delta"; + delta: string; + text: string; + } + | { + type: "operation"; + operation: AIRequestedOperation; + phase: "preview" | "final" | "conflict"; + text?: string; + reason?: string; + } + | { + type: "app-partial"; + data: unknown; + final: boolean; + } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + } + | { + type: "tool-output"; + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + } + | { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + } + | { + type: "structured-preview"; + preview: GenerationStructuredPreviewState; + patches: readonly { + op: "add" | "remove" | "replace"; + path: string; + value?: unknown; + }[]; + } + | { + type: "generation-finish"; + status: GenerationState["status"]; + text: string; + }; + +export function createAIStreamEvent( + generation: Pick< + GenerationState, + "id" | "zoneId" | "blockId" | "sessionId" + >, + event: AIStreamEventInput, +): AIStreamEvent { + return { + ...event, + generationId: generation.id, + sessionId: generation.sessionId, + zoneId: generation.zoneId, + blockId: generation.blockId, + timestamp: Date.now(), + }; +} + +export function resolvePromptTarget( + selection: SelectionState, + target: "auto" | "selection" | "block" | "document" | undefined, +): "selection" | "block" | "document" { + if (target === "selection") { + return "selection"; + } + if (target === "block") { + return "block"; + } + if (target === "document") { + return "document"; + } + return selection?.type === "text" && !selection.isCollapsed + ? "selection" + : "block"; +} + +export function resolveSessionTarget( + editor: Editor, + target: "auto" | "selection" | "block" | "document" | undefined, +): AISessionTarget { + if (target === "document") { + return { kind: "document" }; + } + const selection = editor.selection; + if ( + (target === "selection" || target === "auto") && + selection?.type === "text" && + !selection.isCollapsed + ) { + const range = selection.toRange(); + const selectionSnapshot = resolveSessionSelectionSnapshot(selection); + return { + kind: "selection", + selection: recreateTextSelection(editor, selectionSnapshot), + blockId: range.start.blockId, + }; + } + const blockId = + target === "block" || target === "auto" + ? (resolveActiveBlockId(selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null) + : null; + return blockId ? { kind: "block", blockId } : { kind: "document" }; +} + +export function resolveSessionAnchor( + selection: SelectionState | TextSelection, +): AISession["anchor"] | undefined { + if (selection?.type !== "text") { + return undefined; + } + const range = selection.toRange(); + return { + blockId: range.start.blockId, + from: range.start.offset, + to: range.end.offset, + }; +} + +export function resolveSessionSelectionSnapshot( + selection: TextSelection, +): AISessionSelectionSnapshot { + return { + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + blockRange: [...selection.blockRange], + isMultiBlock: selection.isMultiBlock, + }; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts new file mode 100644 index 0000000..ab7b049 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart2.ts @@ -0,0 +1,431 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveContextualPromptAnchor( + target: AISessionTarget, +): NonNullable["anchor"] { + if (target.kind === "selection") { + const range = target.selection.toRange(); + return { + kind: "text-range", + selectionSnapshot: resolveSessionSelectionSnapshot( + target.selection, + ), + focusBlockId: range.start.blockId, + status: "valid", + lastResolvedRect: null, + }; + } + if (target.kind === "block") { + return { + kind: "block", + focusBlockId: target.blockId, + status: "valid", + lastResolvedRect: null, + }; + } + return { + kind: "document", + focusBlockId: null, + status: "valid", + lastResolvedRect: null, + }; +} + +export function resolveContextualPromptState( + target: AISessionTarget, +): NonNullable { + return { + anchor: resolveContextualPromptAnchor(target), + composer: { + draftPrompt: "", + isOpen: true, + isSubmitting: false, + canSubmitFollowUp: true, + openReason: "user", + }, + }; +} + +export function createInlineHistorySnapshot( + editor: Editor, + sessions: readonly AISession[], + activeSessionId: string | null, + documentVersion: number, + options?: { + kind?: AIInlineHistorySnapshot["kind"]; + }, +): AIInlineHistorySnapshot { + return { + id: crypto.randomUUID(), + sessionId: activeSessionId, + sessions: cloneInlineHistorySessions(editor, sessions), + activeSessionId, + documentVersion, + kind: options?.kind ?? "document-coupled", + }; +} + +export function cloneSessionTarget( + editor: Editor, + target: AISessionTarget, +): AISessionTarget { + if (target.kind !== "selection") { + return { ...target }; + } + return { + kind: "selection", + blockId: target.blockId, + selection: recreateTextSelection( + editor, + resolveSessionSelectionSnapshot(target.selection), + ), + }; +} + +export function cloneInlineHistorySessions( + editor: Editor, + sessions: readonly AISession[], +): AISession[] { + return sessions.map((session) => ({ + ...session, + target: cloneSessionTarget(editor, session.target), + contextualPrompt: session.contextualPrompt + ? { + ...session.contextualPrompt, + anchor: { + ...session.contextualPrompt.anchor, + selectionSnapshot: session.contextualPrompt.anchor + .selectionSnapshot + ? { + ...session.contextualPrompt.anchor + .selectionSnapshot, + anchor: { + ...session.contextualPrompt.anchor + .selectionSnapshot.anchor, + }, + focus: { + ...session.contextualPrompt.anchor + .selectionSnapshot.focus, + }, + blockRange: [ + ...session.contextualPrompt.anchor + .selectionSnapshot.blockRange, + ], + } + : undefined, + }, + composer: { + ...session.contextualPrompt.composer, + }, + } + : undefined, + turns: session.turns.map((turn) => ({ + ...turn, + suggestionIds: [...turn.suggestionIds], + reviewItemIds: [...turn.reviewItemIds], + anchor: turn.anchor ? { ...turn.anchor } : undefined, + selection: turn.selection + ? { + ...turn.selection, + anchor: { ...turn.selection.anchor }, + focus: { ...turn.selection.focus }, + blockRange: [...turn.selection.blockRange], + } + : undefined, + })), + promptHistory: session.promptHistory.map((prompt) => ({ ...prompt })), + generationIds: [...session.generationIds], + pendingSuggestionIds: [...session.pendingSuggestionIds], + pendingReviewItemIds: [...session.pendingReviewItemIds], + metrics: { + ...session.metrics, + fastApply: { ...session.metrics.fastApply }, + }, + anchor: session.anchor ? { ...session.anchor } : undefined, + })); +} + +export function recreateTextSelection( + editor: Editor, + snapshot: AISessionSelectionSnapshot, +): TextSelection { + const blockRange = resolveSelectionSnapshotBlockRange(editor, snapshot); + const isCollapsed = + snapshot.anchor.blockId === snapshot.focus.blockId && + snapshot.anchor.offset === snapshot.focus.offset; + const documentRange = { + start: resolveSelectionSnapshotRangeStart(snapshot, blockRange), + end: resolveSelectionSnapshotRangeEnd(snapshot, blockRange), + get isMultiBlock() { + return blockRange.length > 1; + }, + get blockRange() { + return [...blockRange]; + }, + contains(point: { blockId: string; offset: number }): boolean { + if (!blockRange.includes(point.blockId)) { + return false; + } + const isSingleBlock = blockRange.length === 1; + if (isSingleBlock) { + return ( + point.offset >= this.start.offset && + point.offset <= this.end.offset + ); + } + if (point.blockId === this.start.blockId) { + return point.offset >= this.start.offset; + } + if (point.blockId === this.end.blockId) { + return point.offset <= this.end.offset; + } + return true; + }, + overlaps(other: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + contains: (point: { blockId: string; offset: number }) => boolean; + }): boolean { + return ( + this.contains(other.start) || + this.contains(other.end) || + other.contains(this.start) + ); + }, + equals(other: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + }): boolean { + return ( + this.start.blockId === other.start.blockId && + this.start.offset === other.start.offset && + this.end.blockId === other.end.blockId && + this.end.offset === other.end.offset + ); + }, + toTextSelection() { + return recreateTextSelection(editor, snapshot); + }, + }; + return { + type: "text", + anchor: { ...snapshot.anchor }, + focus: { ...snapshot.focus }, + get isCollapsed() { + return isCollapsed; + }, + get isMultiBlock() { + return blockRange.length > 1; + }, + get blockRange() { + return [...blockRange]; + }, + toRange() { + return documentRange; + }, + }; +} + +export function resolveSelectionSnapshotBlockRange( + editor: Editor, + snapshot: AISessionSelectionSnapshot, +): string[] { + if (snapshot.blockRange.length > 0) { + return [...snapshot.blockRange]; + } + const blockOrder = editor.documentState.blockOrder; + const anchorIndex = blockOrder.indexOf(snapshot.anchor.blockId); + const focusIndex = blockOrder.indexOf(snapshot.focus.blockId); + if (anchorIndex === -1 || focusIndex === -1) { + return [snapshot.anchor.blockId]; + } + const startIndex = Math.min(anchorIndex, focusIndex); + const endIndex = Math.max(anchorIndex, focusIndex); + return blockOrder.slice(startIndex, endIndex + 1); +} + +export function resolveSelectionSnapshotRangeStart( + snapshot: AISessionSelectionSnapshot, + blockRange: readonly string[], +): { blockId: string; offset: number } { + if (blockRange.length <= 1) { + return { + blockId: snapshot.anchor.blockId, + offset: Math.min(snapshot.anchor.offset, snapshot.focus.offset), + }; + } + const firstBlockId = blockRange[0] ?? snapshot.anchor.blockId; + return snapshot.anchor.blockId === firstBlockId + ? { ...snapshot.anchor } + : { ...snapshot.focus }; +} + +export function resolveSelectionSnapshotRangeEnd( + snapshot: AISessionSelectionSnapshot, + blockRange: readonly string[], +): { blockId: string; offset: number } { + if (blockRange.length <= 1) { + return { + blockId: snapshot.anchor.blockId, + offset: Math.max(snapshot.anchor.offset, snapshot.focus.offset), + }; + } + const lastBlockId = + blockRange[blockRange.length - 1] ?? snapshot.focus.blockId; + return snapshot.anchor.blockId === lastBlockId + ? { ...snapshot.anchor } + : { ...snapshot.focus }; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts new file mode 100644 index 0000000..0d70ac4 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart3.ts @@ -0,0 +1,465 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveRequestedOperationForSession( + editor: Editor, + session: AISession, + prompt: string, + options: AICommandExecutionOptions | undefined, + documentVersion: number, +): AIRequestedOperation { + const explicitTarget = options?.target; + const promptIntent = classifyPromptIntent(prompt); + const capturedSelection = resolveSessionSelectionTarget(editor, session); + const liveSelection = + session.surface === "inline-edit" + ? capturedSelection + : editor.selection?.type === "text" && !editor.selection.isCollapsed + ? editor.selection + : capturedSelection; + const activeBlockId = + options?.blockId ?? + resolveSessionBlockId(editor, session) ?? + resolveActiveBlockId(editor.selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null; + const documentActiveBlockId = + options?.blockId ?? + resolveActiveBlockId(editor.selection) ?? + session.anchor?.blockId ?? + null; + const resolvedEditProposal = resolveResolvedEditProposal( + editor, + session, + prompt, + promptIntent, + explicitTarget, + liveSelection, + "markdown", + ); + const clearDocument = + session.target.kind === "document" && isClearDocumentPrompt(prompt); + const documentBlockIds = editor.documentState.blockOrder.filter( + (blockId) => editor.getBlock(blockId) != null, + ); + const documentTransformPlan = clearDocument + ? { + blockIds: documentBlockIds, + placement: "replace-blocks" as const, + transform: "remove" as const, + } + : undefined; + + if (resolvedEditProposal) { + return createRewriteSelectionOperationFromResolvedTarget( + editor, + resolvedEditProposal.target, + resolvedEditProposal.promptIntent, + documentVersion, + ); + } + if (promptIntent === "continue" && activeBlockId) { + if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { + return createDocumentTransformOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + { + blockIds: [activeBlockId], + placement: "append-after-block", + transform: "write", + }, + ); + } + return createContinueBlockOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + ); + } + if ( + activeBlockId && + (promptIntent === "rewrite" || + (promptIntent === "local-edit" && + (editor.getBlock(activeBlockId)?.textContent().length ?? 0) > + 0) || + explicitTarget === "block") + ) { + if (!canUseLocalBlockTextOperation(editor, activeBlockId)) { + return createDocumentTransformOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + { + blockIds: [activeBlockId], + placement: "replace-blocks", + transform: "rewrite", + }, + ); + } + return createRewriteBlockOperation( + editor, + activeBlockId, + promptIntent, + documentVersion, + ); + } + if (explicitTarget === "document") { + return createDocumentTransformOperation( + editor, + documentActiveBlockId, + promptIntent, + documentVersion, + documentTransformPlan, + ); + } + return createDocumentTransformOperation( + editor, + session.target.kind === "document" + ? documentActiveBlockId + : activeBlockId, + promptIntent, + documentVersion, + documentTransformPlan, + ); +} + +export function resolveLocalOperationContentFormat( + editor: Editor, + operation: AIRequestedOperation, + defaultBlockFormat: AIContentFormat, +): AIContentFormat { + if (operation.kind === "rewrite-selection") { + return operation.target.kind === "scoped-range" + ? operation.target.contentFormat + : "text"; + } + if (operation.kind === "document-transform") { + return defaultBlockFormat; + } + if (operation.kind !== "rewrite-block") { + return "text"; + } + const blockId = + operation.target.kind === "block" ? operation.target.blockId : null; + if (blockId && resolveFullBlockTextSelection(editor, blockId)) { + return "text"; + } + return defaultBlockFormat; +} + +export function canUseLocalBlockTextOperation( + editor: Editor, + blockId: string, +): boolean { + const block = editor.getBlock(blockId); + if (!block) { + return false; + } + const schema = editor.schema.resolve(block.type); + if (!schema || !usesInlineTextSelection(schema)) { + return false; + } + return resolveFullBlockTextSelection(editor, blockId) != null; +} + +export function canReuseBottomChatSessionOperation( + previousOperation: AIRequestedOperation, + nextOperation: AIRequestedOperation, +): boolean { + const previousResolvedTarget = + resolveResolvedEditTargetFromRequestedOperation(previousOperation); + const nextResolvedTarget = + resolveResolvedEditTargetFromRequestedOperation(nextOperation); + if (previousResolvedTarget && nextResolvedTarget) { + return areResolvedEditTargetsEqual( + previousResolvedTarget, + nextResolvedTarget, + ); + } + if (previousOperation.kind !== nextOperation.kind) { + return false; + } + if (previousOperation.target.kind !== nextOperation.target.kind) { + return false; + } + if ( + previousOperation.target.kind === "selection" || + previousOperation.target.kind === "scoped-range" + ) { + if ( + nextOperation.target.kind !== "selection" && + nextOperation.target.kind !== "scoped-range" + ) { + return false; + } + return ( + previousOperation.provenance?.selectionSignature === + nextOperation.provenance?.selectionSignature && + previousOperation.target.sourceText === + nextOperation.target.sourceText + ); + } + if (previousOperation.target.kind === "block") { + if (nextOperation.target.kind !== "block") { + return false; + } + return ( + previousOperation.target.blockId === nextOperation.target.blockId && + previousOperation.provenance?.blockRevision === + nextOperation.provenance?.blockRevision + ); + } + if (nextOperation.target.kind !== "document") { + return false; + } + return ( + previousOperation.target.activeBlockId === + nextOperation.target.activeBlockId && + areStructuredValuesEqual( + previousOperation.target.blockIds ?? [], + nextOperation.target.blockIds ?? [], + ) && + (previousOperation.target.placement ?? null) === + (nextOperation.target.placement ?? null) && + (previousOperation.target.transform ?? null) === + (nextOperation.target.transform ?? null) + ); +} + +export function resolveResolvedEditTargetFromRequestedOperation( + operation: AIRequestedOperation, +): ResolvedEditTarget | null { + if ( + operation.target.kind !== "selection" && + operation.target.kind !== "scoped-range" + ) { + return null; + } + return operation.target; +} + +export function areResolvedEditTargetsEqual( + previousTarget: ResolvedEditTarget, + nextTarget: ResolvedEditTarget, +): boolean { + if (previousTarget.kind !== nextTarget.kind) { + return false; + } + if ( + previousTarget.blockId !== nextTarget.blockId || + previousTarget.sourceText !== nextTarget.sourceText || + previousTarget.anchor.blockId !== nextTarget.anchor.blockId || + previousTarget.anchor.offset !== nextTarget.anchor.offset || + previousTarget.focus.blockId !== nextTarget.focus.blockId || + previousTarget.focus.offset !== nextTarget.focus.offset + ) { + return false; + } + if ( + previousTarget.kind === "scoped-range" && + nextTarget.kind === "scoped-range" + ) { + return ( + previousTarget.scope === nextTarget.scope && + previousTarget.contentFormat === nextTarget.contentFormat && + areStructuredValuesEqual( + previousTarget.blockIds, + nextTarget.blockIds, + ) + ); + } + return true; +} + +export function buildSessionExecutionPrompt( + session: AISession | null, + prompt: string, +): string { + if (!session) { + return prompt; + } + const previousPrompts = session.promptHistory + .map((item) => item.prompt.trim()) + .filter((item) => item.length > 0) + .slice(-4); + if (previousPrompts.length === 0) { + return prompt; + } + const historyLines = previousPrompts.map( + (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, + ); + const intro = + session.surface === "inline-edit" + ? "You are continuing an existing inline editor edit session." + : "You are continuing an existing editor chat session."; + const applyInstruction = + session.surface === "inline-edit" + ? "Apply the latest request to the current selected document state." + : "Apply the latest request to the current document state."; + return [ + intro, + "Earlier user requests in this same session:", + ...historyLines, + "", + applyInstruction, + "Latest request:", + prompt, + ].join("\n"); +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts new file mode 100644 index 0000000..7e118f9 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart4.ts @@ -0,0 +1,402 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function createRewriteSelectionOperation( + editor: Editor, + selection: TextSelection, + promptIntent: string, + documentVersion: number, + options?: { + sourceText?: string; + }, +): AIRequestedOperation { + const range = selection.toRange(); + return { + kind: "rewrite-selection", + applyPolicy: "selection-replace", + promptIntent, + target: { + kind: "selection", + blockId: range.start.blockId, + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + sourceText: + options?.sourceText ?? resolveSelectionText(editor, selection), + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision(range.start.blockId), + selectionSignature: createSelectionSignature(selection), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createRewriteSelectionOperationFromResolvedTarget( + editor: Editor, + target: ResolvedEditTarget, + promptIntent: string, + documentVersion: number, +): AIRequestedOperation { + const selection = recreateTextSelection(editor, { + anchor: target.anchor, + focus: target.focus, + blockRange: resolveSelectionTargetBlockIds(editor, target), + isMultiBlock: + resolveSelectionTargetBlockIds(editor, target).length > 1 || + target.anchor.blockId !== target.focus.blockId, + }); + if (target.kind === "selection") { + return createRewriteSelectionOperation( + editor, + selection, + promptIntent, + documentVersion, + { + sourceText: target.sourceText, + }, + ); + } + return { + kind: "rewrite-selection", + applyPolicy: "selection-replace", + promptIntent, + target: { + kind: "scoped-range", + blockId: target.blockId, + anchor: { ...target.anchor }, + focus: { ...target.focus }, + sourceText: target.sourceText, + blockIds: [...target.blockIds], + contentFormat: target.contentFormat, + scope: target.scope, + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision( + target.blockId ?? selection.anchor.blockId, + ), + selectionSignature: createSelectionSignature(selection), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createRewriteBlockOperation( + editor: Editor, + blockId: string, + promptIntent: string, + documentVersion: number, +): AIRequestedOperation { + const block = editor.getBlock(blockId); + return { + kind: "rewrite-block", + applyPolicy: "block-replace", + promptIntent, + target: { + kind: "block", + blockId, + blockType: block?.type ?? null, + sourceText: block?.textContent() ?? "", + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision(blockId), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createContinueBlockOperation( + editor: Editor, + blockId: string, + promptIntent: string, + documentVersion: number, +): AIRequestedOperation { + const block = editor.getBlock(blockId); + return { + kind: "continue-block", + applyPolicy: "block-continue", + promptIntent, + target: { + kind: "block", + blockId, + blockType: block?.type ?? null, + sourceText: block?.textContent() ?? "", + insertionOffset: resolveContinueInsertionOffset(editor, blockId), + }, + provenance: { + documentVersion, + blockRevision: editor.getBlockRevision(blockId), + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function createDocumentTransformOperation( + editor: Editor, + activeBlockId: string | null, + promptIntent: string, + documentVersion: number, + options?: { + blockIds?: readonly string[]; + placement?: + | "append-after-block" + | "replace-empty-block" + | "replace-blocks"; + transform?: "write" | "rewrite" | "remove"; + }, +): AIRequestedOperation { + return { + kind: "document-transform", + applyPolicy: "document-review", + promptIntent, + target: { + kind: "document", + activeBlockId, + blockIds: options?.blockIds, + placement: options?.placement, + transform: options?.transform, + }, + provenance: { + documentVersion, + syncedGeneration: editor.documentState.generation, + }, + }; +} + +export function resolvePreviousGeneratedBlockIds(session: AISession): string[] { + const completedTurns = session.turns.filter( + (turn) => turn.status === "complete" || turn.status === "accepted", + ); + const lastTurnWithBlocks = completedTurns + .slice() + .reverse() + .find((turn) => turn.generatedBlockIds.length > 0); + return lastTurnWithBlocks?.generatedBlockIds ?? []; +} + +export function shouldReplacePreviousGeneratedBlocks( + session: AISession, + prompt: string, +): boolean { + return ( + session.surface === "bottom-chat" && + session.target.kind === "document" && + (classifyPromptIntent(prompt) === "rewrite" || + isDocumentResetPrompt(prompt) || + isDocumentFollowUpEditPrompt(prompt)) + ); +} + +export function resolveReplacementDeleteBlockIds( + editor: Editor, + blockId: string, + replaceBlockIds?: readonly string[], +): string[] { + const requestedIds = + replaceBlockIds && replaceBlockIds.length > 0 + ? replaceBlockIds + : [blockId]; + const deleteBlockIds = requestedIds.filter( + (candidateBlockId, index, allBlockIds) => + allBlockIds.indexOf(candidateBlockId) === index && + editor.getBlock(candidateBlockId) != null, + ); + return deleteBlockIds.length > 0 ? deleteBlockIds : [blockId]; +} + +export function createResolvedSelectionEditTarget( + editor: Editor, + selection: TextSelection, +): ResolvedEditTarget { + const range = selection.toRange(); + return { + kind: "selection", + blockId: range.start.blockId, + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + sourceText: resolveSelectionText(editor, selection), + }; +} + +export function createResolvedScopedEditTarget( + editor: Editor, + selection: TextSelection, + scope: ModelOperationScopedRangeTarget["scope"], + contentFormat: AIContentFormat, +): ResolvedEditTarget { + const range = selection.toRange(); + return { + kind: "scoped-range", + scope, + blockId: range.start.blockId, + anchor: { ...selection.anchor }, + focus: { ...selection.focus }, + blockIds: [...range.blockRange], + sourceText: resolveSelectionText(editor, selection), + contentFormat, + }; +} + +export function createResolvedEditProposal( + promptIntent: string, + target: ResolvedEditTarget, +): ResolvedEditProposal { + return { + promptIntent, + target, + }; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts new file mode 100644 index 0000000..985a68e --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart5.ts @@ -0,0 +1,432 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveResolvedEditProposal( + editor: Editor, + session: AISession, + prompt: string, + promptIntent: string, + explicitTarget: AICommandExecutionOptions["target"] | undefined, + liveSelection: TextSelection | null, + defaultBlockFormat: AIContentFormat, +): ResolvedEditProposal | null { + if (liveSelection && explicitTarget === "selection") { + return createResolvedEditProposal( + promptIntent, + createResolvedSelectionEditTarget(editor, liveSelection), + ); + } + + const selectionScopedSession = session.target.kind === "selection"; + if ( + liveSelection && + (session.surface === "inline-edit" || + (selectionScopedSession && + (promptIntent === "rewrite" || promptIntent === "local-edit"))) + ) { + return createResolvedEditProposal( + promptIntent, + createResolvedSelectionEditTarget(editor, liveSelection), + ); + } + + if (session.target.kind !== "document" && explicitTarget !== "document") { + return null; + } + if ( + promptIntent === "continue" || + promptIntent === "review" || + promptIntent === "search" || + promptIntent === "structural" + ) { + return null; + } + + const titleSelection = resolveDocumentTitleSelection(editor, prompt); + if (titleSelection) { + return createResolvedEditProposal( + promptIntent, + createResolvedScopedEditTarget( + editor, + titleSelection, + "heading", + defaultBlockFormat, + ), + ); + } + + const paragraphSelection = resolveDocumentParagraphSelection( + editor, + prompt, + ); + if (paragraphSelection) { + return createResolvedEditProposal( + promptIntent, + createResolvedScopedEditTarget( + editor, + paragraphSelection, + "paragraph", + defaultBlockFormat, + ), + ); + } + + const documentBlockIds = editor.documentState.blockOrder.filter( + (blockId) => editor.getBlock(blockId) != null, + ); + const documentHasMeaningfulContent = documentBlockIds.some((blockId) => { + const block = editor.getBlock(blockId); + return (block?.textContent().trim().length ?? 0) > 0; + }); + const shouldRewriteDocumentScope = + !documentHasMeaningfulContent || + promptIntent === "rewrite" || + isClearDocumentPrompt(prompt) || + isWholeDocumentRewritePrompt(prompt) || + isDocumentResetPrompt(prompt) || + isDocumentFollowUpEditPrompt(prompt); + if (!shouldRewriteDocumentScope) { + return null; + } + + const documentSelection = resolveDocumentBlockRangeSelection( + editor, + documentBlockIds, + ); + if (!documentSelection) { + return null; + } + return createResolvedEditProposal( + promptIntent, + createResolvedScopedEditTarget( + editor, + documentSelection, + "document", + defaultBlockFormat, + ), + ); +} + +export function resolveSelectionForRequestedOperation( + editor: Editor, + operation: AIRequestedOperation, +): TextSelection | null { + if ( + operation.target.kind !== "selection" && + operation.target.kind !== "scoped-range" + ) { + return null; + } + return recreateTextSelection(editor, { + anchor: operation.target.anchor, + focus: operation.target.focus, + blockRange: resolveSelectionTargetBlockIds(editor, operation.target), + isMultiBlock: + resolveSelectionTargetBlockIds(editor, operation.target).length > + 1 || + operation.target.anchor.blockId !== operation.target.focus.blockId, + }); +} + +export function resolveFullBlockTextSelection( + editor: Editor, + blockId: string, +): TextSelection | null { + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + return recreateTextSelection(editor, { + anchor: { blockId, offset: 0 }, + focus: { blockId, offset: block.textContent().length }, + blockRange: [blockId], + isMultiBlock: false, + }); +} + +export function resolveDocumentBlockRangeSelection( + editor: Editor, + blockIds: readonly string[], +): TextSelection | null { + const resolvedBlockIds = blockIds.filter( + (blockId, index, allBlockIds) => + allBlockIds.indexOf(blockId) === index && + editor.getBlock(blockId) != null, + ); + const firstBlockId = resolvedBlockIds[0]; + const lastBlockId = resolvedBlockIds[resolvedBlockIds.length - 1]; + if (!firstBlockId || !lastBlockId) { + return null; + } + const lastBlock = editor.getBlock(lastBlockId); + return recreateTextSelection(editor, { + anchor: { blockId: firstBlockId, offset: 0 }, + focus: { + blockId: lastBlockId, + offset: lastBlock?.textContent().length ?? 0, + }, + blockRange: resolvedBlockIds, + isMultiBlock: resolvedBlockIds.length > 1, + }); +} + +export function resolveDocumentTitleSelection( + editor: Editor, + prompt: string, +): TextSelection | null { + if (!/\b(title|heading)\b/i.test(prompt)) { + return null; + } + const headingBlockId = + editor.documentState.blockOrder.find((blockId) => { + const block = editor.getBlock(blockId); + return ( + block?.type === "heading" || block?.type.startsWith("heading-") + ); + }) ?? + editor.firstBlock()?.id ?? + null; + return headingBlockId + ? resolveDocumentBlockRangeSelection(editor, [headingBlockId]) + : null; +} + +export function resolveDocumentParagraphSelection( + editor: Editor, + prompt: string, +): TextSelection | null { + const paragraphIndex = parseParagraphReference(prompt); + if (paragraphIndex == null) { + return null; + } + const paragraphBlockIds = editor.documentState.blockOrder.filter( + (blockId) => { + const block = editor.getBlock(blockId); + if (!block) { + return false; + } + return ( + block.type === "paragraph" || + (block.textContent().trim().length > 0 && + block.type !== "heading" && + !block.type.startsWith("heading-")) + ); + }, + ); + const targetParagraphBlockId = + paragraphBlockIds[paragraphIndex - 1] ?? null; + return targetParagraphBlockId + ? resolveDocumentBlockRangeSelection(editor, [targetParagraphBlockId]) + : null; +} + +export function parseParagraphReference(prompt: string): number | null { + const match = prompt.match( + /\b(?:(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)|(\d+)(?:st|nd|rd|th))\s+paragraph\b/i, + ); + if (!match) { + return null; + } + const wordOrdinal = match[1]?.toLowerCase(); + if (wordOrdinal) { + return resolveWordOrdinal(wordOrdinal); + } + const numericOrdinal = Number.parseInt(match[2] ?? "", 10); + return Number.isFinite(numericOrdinal) && numericOrdinal > 0 + ? numericOrdinal + : null; +} + +export function resolveWordOrdinal(word: string): number | null { + switch (word) { + case "first": + return 1; + case "second": + return 2; + case "third": + return 3; + case "fourth": + return 4; + case "fifth": + return 5; + case "sixth": + return 6; + case "seventh": + return 7; + case "eighth": + return 8; + case "ninth": + return 9; + case "tenth": + return 10; + default: + return null; + } +} + +export function resolveBlockIdForRequestedOperation( + operation: AIRequestedOperation, +): string | null { + if (operation.target.kind === "block") { + return operation.target.blockId; + } + if ( + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" + ) { + return operation.target.blockId; + } + return operation.target.activeBlockId; +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts new file mode 100644 index 0000000..06552e7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart6.ts @@ -0,0 +1,481 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveRequestedOperationConflict( + editor: Editor, + operation: AIRequestedOperation, + currentSelectionSignature: string | null, +): string | null { + if ( + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" + ) { + const selection = resolveSelectionForRequestedOperation( + editor, + operation, + ); + if (!selection) { + return "The selected range no longer exists."; + } + if (isScopedSelectionTarget(operation.target)) { + if ( + renderSelectionTargetBlockText(editor, operation.target) !== + operation.target.sourceText + ) { + return "The selected text changed before the rewrite completed."; + } + return null; + } + if ( + operation.provenance?.selectionSignature != null && + operation.provenance.selectionSignature !== + currentSelectionSignature + ) { + return "The selected range changed before the rewrite completed."; + } + if ( + resolveSelectionText(editor, selection) !== + operation.target.sourceText + ) { + return "The selected text changed before the rewrite completed."; + } + return null; + } + if (operation.target.kind === "block") { + const block = editor.getBlock(operation.target.blockId); + if (!block) { + return "The target block no longer exists."; + } + if ( + operation.provenance?.blockRevision != null && + editor.getBlockRevision(operation.target.blockId) !== + operation.provenance.blockRevision + ) { + return "The target block changed before the operation completed."; + } + return null; + } + if ( + operation.provenance?.syncedGeneration != null && + editor.documentState.generation !== + operation.provenance.syncedGeneration + ) { + return "The document changed before the operation completed."; + } + return null; +} + +export function resolveContinueInsertionOffset( + editor: Editor, + blockId: string, +): number { + const selection = editor.selection; + if ( + selection?.type === "text" && + selection.isCollapsed && + selection.anchor.blockId === blockId + ) { + return selection.anchor.offset; + } + return resolveBlockInsertionOffset(editor, blockId); +} + +export function createSelectionSignature(selection: TextSelection): string { + return [ + "text", + selection.anchor.blockId, + selection.anchor.offset, + selection.focus.blockId, + selection.focus.offset, + String(selection.isCollapsed), + ].join(":"); +} + +export function resolveSessionSelectionTarget( + editor: Editor, + session: AISession, +): TextSelection | null { + const anchorSelection = session.contextualPrompt?.anchor.selectionSnapshot; + if (session.target.kind !== "selection" && !anchorSelection) { + return null; + } + const activeTurnSelection = session.activeTurnId + ? session.turns.find((turn) => turn.id === session.activeTurnId) + ?.selection + : session.turns[session.turns.length - 1]?.selection; + if (activeTurnSelection) { + const restoredSelection = recreateTextSelection( + editor, + activeTurnSelection, + ); + if (!restoredSelection.isCollapsed) { + return restoredSelection; + } + } + const selection = editor.selection; + if ( + selection?.type === "text" && + !selection.isCollapsed && + selectionMatchesSnapshot( + selection, + session.target.kind === "selection" + ? resolveSessionSelectionSnapshot(session.target.selection) + : (anchorSelection ?? null), + ) + ) { + return selection; + } + if (anchorSelection) { + const restoredSelection = recreateTextSelection( + editor, + anchorSelection, + ); + if (!restoredSelection.isCollapsed) { + return restoredSelection; + } + } + if ( + session.target.kind === "selection" && + !session.target.selection.isCollapsed + ) { + return session.target.selection; + } + return null; +} + +export function resolveLiveInlineSelectionTarget( + editor: Editor, +): Extract | null { + const selection = editor.selection; + if (selection?.type !== "text" || selection.isCollapsed) { + return null; + } + const target = resolveSessionTarget(editor, "selection"); + return target.kind === "selection" ? target : null; +} + +export function resolvePendingInlineSelectionTarget( + editor: Editor, + operation: AIRequestedOperation | undefined, + suggestionIds: readonly string[], +): Extract | null { + if ( + operation?.kind !== "rewrite-selection" || + operation.target.kind !== "selection" || + operation.target.anchor.blockId !== operation.target.focus.blockId + ) { + return null; + } + const textSuggestions = readAllSuggestions(editor).filter( + (suggestion): suggestion is PersistentTextSuggestion => + suggestion.kind === "text" && + (suggestion.action === "insert" || + suggestion.action === "delete") && + suggestionIds.includes(suggestion.id), + ); + if (textSuggestions.length === 0) { + return null; + } + const blockId = operation.target.anchor.blockId; + const startOffset = Math.min( + operation.target.anchor.offset, + operation.target.focus.offset, + ); + const previewSpanLength = textSuggestions.reduce( + (totalLength, suggestion) => totalLength + suggestion.length, + 0, + ); + const endOffset = startOffset + previewSpanLength; + if (endOffset <= startOffset) { + return null; + } + return { + kind: "selection", + blockId, + selection: recreateTextSelection(editor, { + anchor: { blockId, offset: startOffset }, + focus: { blockId, offset: endOffset }, + blockRange: [blockId], + isMultiBlock: false, + }), + }; +} + +export function resolveAcceptedInlineSelectionTarget( + editor: Editor, + operation: AIRequestedOperation | undefined, + suggestionIds: readonly string[], +): Extract | null { + if ( + operation?.kind !== "rewrite-selection" || + operation.target.kind !== "selection" || + operation.target.anchor.blockId !== operation.target.focus.blockId + ) { + return null; + } + const insertSuggestions = readAllSuggestions(editor).filter( + (suggestion): suggestion is PersistentTextSuggestion => + suggestion.kind === "text" && + suggestion.action === "insert" && + suggestionIds.includes(suggestion.id), + ); + if (insertSuggestions.length === 0) { + return null; + } + const blockId = operation.target.anchor.blockId; + const startOffset = Math.min( + operation.target.anchor.offset, + operation.target.focus.offset, + ); + const insertedLength = insertSuggestions.reduce( + (totalLength, suggestion) => totalLength + suggestion.length, + 0, + ); + const endOffset = startOffset + insertedLength; + if (endOffset <= startOffset) { + return null; + } + return { + kind: "selection", + blockId, + selection: recreateTextSelection(editor, { + anchor: { blockId, offset: startOffset }, + focus: { blockId, offset: endOffset }, + blockRange: [blockId], + isMultiBlock: false, + }), + }; +} + +export function shouldCloseInlineSessionPrompt(session: AISession): boolean { + return ( + session.surface === "inline-edit" && session.contextualPrompt != null + ); +} + +export function closeInlineSessionPrompt( + session: AISession, +): AISession["contextualPrompt"] | undefined { + if (!shouldCloseInlineSessionPrompt(session) || !session.contextualPrompt) { + return session.contextualPrompt; + } + + return { + ...session.contextualPrompt, + composer: { + ...session.contextualPrompt.composer, + isOpen: false, + isSubmitting: false, + }, + }; +} + +export function createDefaultSessionFastApplyMetrics(): AISessionMetrics["fastApply"] { + return { + attemptCount: 0, + nativeFastApplyCount: 0, + scopedReplacementCount: 0, + plainMarkdownCount: 0, + failedCount: 0, + }; +} + +export function accumulateSessionFastApplyMetrics( + current: AISessionMetrics["fastApply"] | undefined, + fastApply: FastApplyDebugState | undefined, +): AISessionMetrics["fastApply"] { + const next = { + ...(current ?? createDefaultSessionFastApplyMetrics()), + }; + if (!fastApply?.attempted) { + return next; + } + next.attemptCount += 1; + switch (fastApply.executionPath) { + case "native-fast-apply": + next.nativeFastApplyCount += 1; + return next; + case "scoped-replacement": + next.scopedReplacementCount += 1; + return next; + case "plain-markdown": + next.plainMarkdownCount += 1; + return next; + default: + next.failedCount += 1; + return next; + } +} + +export function selectionMatchesSnapshot( + selection: TextSelection, + snapshot: AISessionSelectionSnapshot | null, +): boolean { + if (!snapshot) { + return false; + } + + return ( + selection.anchor.blockId === snapshot.anchor.blockId && + selection.anchor.offset === snapshot.anchor.offset && + selection.focus.blockId === snapshot.focus.blockId && + selection.focus.offset === snapshot.focus.offset && + selection.isMultiBlock === snapshot.isMultiBlock && + selection.blockRange.length === snapshot.blockRange.length && + selection.blockRange.every( + (blockId, index) => blockId === snapshot.blockRange[index], + ) + ); +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts new file mode 100644 index 0000000..452e4a2 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart7.ts @@ -0,0 +1,484 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function resolveSessionSelectionSnapshots( + session: AISession, +): readonly AISessionSelectionSnapshot[] { + const snapshots: AISessionSelectionSnapshot[] = []; + const activeTurn = + session.activeTurnId != null + ? (session.turns.find((turn) => turn.id === session.activeTurnId) ?? + null) + : (session.turns[session.turns.length - 1] ?? null); + if (activeTurn?.selection) { + snapshots.push(activeTurn.selection); + } + if (session.contextualPrompt?.anchor.selectionSnapshot) { + snapshots.push(session.contextualPrompt.anchor.selectionSnapshot); + } + if (session.target.kind === "selection") { + snapshots.push( + resolveSessionSelectionSnapshot(session.target.selection), + ); + } + return snapshots; +} + +export function sessionTargetMatches( + session: AISession, + target: AISessionTarget, +): boolean { + if (session.target.kind !== target.kind) { + return false; + } + if (target.kind !== "selection") { + return areStructuredValuesEqual(session.target, target); + } + return sessionSelectionMatches(session, target.selection); +} + +export function sessionSelectionMatches( + session: AISession, + selection: TextSelection, +): boolean { + return resolveSessionSelectionSnapshots(session).some((snapshot) => + selectionMatchesSnapshot(selection, snapshot), + ); +} + +export function resolveSessionBlockId( + editor: Editor, + session: AISession, +): string | null { + if (session.target.kind === "block") { + return session.target.blockId; + } + if (session.target.kind === "selection") { + return session.target.blockId; + } + return ( + resolveActiveBlockId(editor.selection) ?? + editor.lastBlock()?.id ?? + editor.firstBlock()?.id ?? + null + ); +} + +export function resolveBlockInsertionOffset(editor: Editor, blockId: string): number { + const selection = editor.selection; + const block = editor.getBlock(blockId); + const fallbackOffset = + block && isVisuallyEmptyInlineText(block.textContent()) + ? 0 + : (block?.textContent().length ?? 0); + if (selection?.type !== "text") { + return fallbackOffset; + } + const range = selection.toRange(); + if (selection.isCollapsed) { + return selection.anchor.blockId === blockId + ? selection.anchor.offset + : fallbackOffset; + } + if (range.start.blockId === blockId && range.end.blockId === blockId) { + return range.end.offset; + } + if (range.end.blockId === blockId) { + return range.end.offset; + } + if (range.start.blockId === blockId) { + return range.start.offset; + } + return fallbackOffset; +} + +export function appendUniqueString( + values: readonly string[], + value: string, +): string[] { + return values.includes(value) ? [...values] : [...values, value]; +} + +export function areSuggestionsEqual( + previous: readonly PersistentSuggestion[], + next: readonly PersistentSuggestion[], +): boolean { + if (previous.length !== next.length) { + return false; + } + + for (let index = 0; index < previous.length; index += 1) { + const previousSuggestion = previous[index]; + const nextSuggestion = next[index]; + if ( + previousSuggestion.id !== nextSuggestion.id || + previousSuggestion.kind !== nextSuggestion.kind || + previousSuggestion.blockId !== nextSuggestion.blockId || + previousSuggestion.action !== nextSuggestion.action || + previousSuggestion.author !== nextSuggestion.author || + previousSuggestion.authorType !== nextSuggestion.authorType || + previousSuggestion.createdAt !== nextSuggestion.createdAt || + previousSuggestion.model !== nextSuggestion.model || + previousSuggestion.sessionId !== nextSuggestion.sessionId + ) { + return false; + } + if ( + previousSuggestion.kind === "text" && + nextSuggestion.kind === "text" && + (previousSuggestion.offset !== nextSuggestion.offset || + previousSuggestion.length !== nextSuggestion.length) + ) { + return false; + } + if ( + previousSuggestion.kind === "block" && + nextSuggestion.kind === "block" && + JSON.stringify(previousSuggestion.previousState) !== + JSON.stringify(nextSuggestion.previousState) + ) { + return false; + } + } + + return true; +} + +export function areAIControllerStatesEqual( + previous: AIControllerState, + next: AIControllerState, +): boolean { + if ( + previous.status !== next.status || + previous.activeSessionId !== next.activeSessionId || + previous.suggestMode !== next.suggestMode || + previous.commandMenuOpen !== next.commandMenuOpen || + previous.lastRoute !== next.lastRoute || + !areStructuredValuesEqual( + previous.streamingReviewPreview, + next.streamingReviewPreview, + ) + ) { + return false; + } + + if ( + !areGenerationsEqual(previous.activeGeneration, next.activeGeneration) + ) { + return false; + } + + if ( + !areEphemeralSuggestionsEqual( + previous.ephemeralSuggestion, + next.ephemeralSuggestion, + ) + ) { + return false; + } + + return areSessionsEqual(previous.sessions, next.sessions); +} + +export function areGenerationsEqual( + previous: AIControllerState["activeGeneration"], + next: AIControllerState["activeGeneration"], +): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + + if ( + previous.id !== next.id || + previous.zoneId !== next.zoneId || + previous.blockId !== next.blockId || + previous.target !== next.target || + previous.sessionId !== next.sessionId || + previous.surface !== next.surface || + previous.prompt !== next.prompt || + previous.status !== next.status || + previous.tokenCount !== next.tokenCount || + previous.undoGroupId !== next.undoGroupId || + previous.text !== next.text || + previous.commandId !== next.commandId || + previous.contentFormat !== next.contentFormat || + previous.route !== next.route || + previous.mutationMode !== next.mutationMode || + previous.planState !== next.planState || + previous.targetKind !== next.targetKind || + !areStructuredValuesEqual( + previous.structuredPreview, + next.structuredPreview, + ) || + !areStructuredValuesEqual(previous.reviewItems, next.reviewItems) || + !areStructuredValuesEqual(previous.plan, next.plan) || + !areStructuredValuesEqual(previous.debug, next.debug) + ) { + return false; + } + + if (!areStringArraysEqual(previous.suggestionIds, next.suggestionIds)) { + return false; + } + + if (previous.steps.length !== next.steps.length) { + return false; + } + + for (let index = 0; index < previous.steps.length; index += 1) { + const previousStep = previous.steps[index]; + const nextStep = next.steps[index]; + if ( + previousStep.index !== nextStep.index || + previousStep.type !== nextStep.type || + previousStep.toolName !== nextStep.toolName || + previousStep.toolCallId !== nextStep.toolCallId || + previousStep.status !== nextStep.status || + previousStep.input !== nextStep.input || + previousStep.output !== nextStep.output + ) { + return false; + } + } + + return true; +} + +export function areSessionsEqual( + previous: readonly AISession[], + next: readonly AISession[], +): boolean { + if (previous.length !== next.length) { + return false; + } + for (let index = 0; index < previous.length; index += 1) { + const previousSession = previous[index]; + const nextSession = next[index]; + if ( + !previousSession || + !nextSession || + previousSession.id !== nextSession.id || + previousSession.surface !== nextSession.surface || + previousSession.status !== nextSession.status || + previousSession.createdAt !== nextSession.createdAt || + previousSession.updatedAt !== nextSession.updatedAt || + previousSession.activeTurnId !== nextSession.activeTurnId || + !areStructuredValuesEqual( + previousSession.target, + nextSession.target, + ) || + !areStructuredValuesEqual( + previousSession.anchor, + nextSession.anchor, + ) || + !areStructuredValuesEqual( + previousSession.contextualPrompt, + nextSession.contextualPrompt, + ) || + !areStructuredValuesEqual( + previousSession.turns, + nextSession.turns, + ) || + !areStructuredValuesEqual( + previousSession.promptHistory, + nextSession.promptHistory, + ) || + !areStringArraysEqual( + previousSession.generationIds, + nextSession.generationIds, + ) || + !areStringArraysEqual( + previousSession.pendingSuggestionIds, + nextSession.pendingSuggestionIds, + ) || + !areStringArraysEqual( + previousSession.pendingReviewItemIds, + nextSession.pendingReviewItemIds, + ) || + !areStructuredValuesEqual( + previousSession.metrics, + nextSession.metrics, + ) + ) { + return false; + } + } + return true; +} + +export function areInlineHistorySnapshotsEqual( + previous: AIInlineHistorySnapshot, + next: AIInlineHistorySnapshot, +): boolean { + return ( + previous.activeSessionId === next.activeSessionId && + previous.documentVersion === next.documentVersion && + previous.kind === next.kind && + areSessionsEqual(previous.sessions, next.sessions) + ); +} + +export function didInlineHistoryCheckpointChange( + previousState: AIControllerState, + nextState: AIControllerState, +): boolean { + return !areStructuredValuesEqual( + buildInlineHistoryCheckpoint(previousState), + buildInlineHistoryCheckpoint(nextState), + ); +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts new file mode 100644 index 0000000..a36b7e7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart8.ts @@ -0,0 +1,415 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildSelectionReplacementOps, sliceInlineDeltasFromOffset, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, trimLeadingBlankBlockGenerationText, isVisuallyEmptyInlineText } from "./extensionHelpersPart9"; + +export function buildInlineHistoryCheckpoint(state: AIControllerState): { + activeSessionId: string | null; + sessions: Array<{ + id: string; + isOpen: boolean; + target: AISessionSelectionSnapshot | null; + latestSettledTurn: { + id: string; + prompt: string; + selection: AISessionSelectionSnapshot | null; + } | null; + settledTurnCount: number; + }>; +} { + const inlineSessions = state.sessions.filter( + (session) => session.surface === "inline-edit", + ); + return { + activeSessionId: state.activeSessionId ?? null, + sessions: inlineSessions.map((session) => { + const settledTurns = session.turns.filter( + (turn) => turn.status !== "streaming", + ); + const latestSettledTurn = + settledTurns[settledTurns.length - 1] ?? null; + return { + id: session.id, + isOpen: session.contextualPrompt?.composer.isOpen ?? false, + target: + session.contextualPrompt?.anchor.selectionSnapshot ?? + (session.target.kind === "selection" + ? resolveSessionSelectionSnapshot( + session.target.selection, + ) + : null), + latestSettledTurn: latestSettledTurn + ? { + id: latestSettledTurn.id, + prompt: latestSettledTurn.prompt, + selection: latestSettledTurn.selection ?? null, + } + : null, + settledTurnCount: settledTurns.length, + }; + }), + }; +} + +export function countSettledInlineTurns( + snapshot: AIInlineHistorySnapshot, + sessionId?: string | null, +): number { + if (sessionId) { + const session = snapshot.sessions.find( + (item) => item.id === sessionId && item.surface === "inline-edit", + ); + if (!session) { + return 0; + } + return session.turns.filter((turn) => turn.status !== "streaming") + .length; + } + return snapshot.sessions + .filter((session) => session.surface === "inline-edit") + .reduce( + (count, session) => + count + + session.turns.filter((turn) => turn.status !== "streaming") + .length, + 0, + ); +} + +export function hasStreamingInlineTurns( + snapshot: AIInlineHistorySnapshot, + sessionId?: string | null, +): boolean { + if (sessionId) { + const session = snapshot.sessions.find( + (item) => item.id === sessionId && item.surface === "inline-edit", + ); + return ( + session?.turns.some((turn) => turn.status === "streaming") ?? false + ); + } + return snapshot.sessions + .filter((session) => session.surface === "inline-edit") + .some((session) => + session.turns.some((turn) => turn.status === "streaming"), + ); +} + +export function resolveInlineShortcutHistoryState( + snapshot: AIInlineHistorySnapshot, + sessionId: string | null, +): AIInlineShortcutHistoryState | null { + const session = sessionId + ? (snapshot.sessions.find( + (item) => + item.id === sessionId && item.surface === "inline-edit", + ) ?? null) + : null; + if (!session) { + return { + sessionId: null, + phase: "none", + turnCount: 0, + turnId: null, + }; + } + const durableTurns = session.turns.filter( + (turn) => turn.status !== "streaming" && turn.status !== "cancelled", + ); + if (durableTurns.length === 0) { + return { + sessionId: null, + phase: "none", + turnCount: 0, + turnId: null, + }; + } + const latestTurn = durableTurns[durableTurns.length - 1] ?? null; + if (!latestTurn) { + return null; + } + if (latestTurn.status === "review") { + return { + sessionId, + phase: "review", + turnCount: durableTurns.length, + turnId: latestTurn.id, + }; + } + if (latestTurn.status === "accepted" || latestTurn.status === "rejected") { + return { + sessionId, + phase: "resolved", + turnCount: durableTurns.length, + turnId: latestTurn.id, + resolution: latestTurn.status, + }; + } + return null; +} + +export function areInlineShortcutHistoryStatesEqual( + left: AIInlineShortcutHistoryState, + right: AIInlineShortcutHistoryState, +): boolean { + return ( + left.sessionId === right.sessionId && + left.phase === right.phase && + left.turnCount === right.turnCount && + left.turnId === right.turnId && + left.resolution === right.resolution + ); +} + +export function shouldReplaceInlineShortcutWaypointRepresentative( + state: AIInlineShortcutHistoryState, + currentSnapshot: AIInlineHistorySnapshot | null, + nextSnapshot: AIInlineHistorySnapshot, +): boolean { + if (!currentSnapshot) { + return true; + } + const currentSession = state.sessionId + ? (currentSnapshot.sessions.find( + (session) => + session.id === state.sessionId && + session.surface === "inline-edit", + ) ?? null) + : null; + const nextSession = state.sessionId + ? (nextSnapshot.sessions.find( + (session) => + session.id === state.sessionId && + session.surface === "inline-edit", + ) ?? null) + : null; + if (state.phase === "review") { + const currentOpen = + currentSession?.contextualPrompt?.composer.isOpen === true; + const nextOpen = + nextSession?.contextualPrompt?.composer.isOpen === true; + if (currentOpen !== nextOpen) { + return nextOpen; + } + } + if (state.phase === "resolved") { + const currentOpen = + currentSession?.contextualPrompt?.composer.isOpen === true; + const nextOpen = + nextSession?.contextualPrompt?.composer.isOpen === true; + if (currentOpen !== nextOpen) { + return !nextOpen; + } + } + return true; +} + +export function areEphemeralSuggestionsEqual( + previous: AIControllerState["ephemeralSuggestion"], + next: AIControllerState["ephemeralSuggestion"], +): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + + return ( + previous.id === next.id && + previous.blockId === next.blockId && + previous.offset === next.offset && + previous.text === next.text && + previous.type === next.type && + previous.blockType === next.blockType && + previous.props === next.props + ); +} + +export function areStringArraysEqual( + previous: readonly string[] | undefined, + next: readonly string[] | undefined, +): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + if (previous.length !== next.length) { + return false; + } + + for (let index = 0; index < previous.length; index += 1) { + if (previous[index] !== next[index]) { + return false; + } + } + + return true; +} + +export function areStructuredValuesEqual(previous: unknown, next: unknown): boolean { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + + try { + return JSON.stringify(previous) === JSON.stringify(next); + } catch { + return false; + } +} diff --git a/packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts b/packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts new file mode 100644 index 0000000..59edcf3 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/extensionHelpersPart9.ts @@ -0,0 +1,344 @@ +// @ts-nocheck +import { + createDecorationSet, + ensureInlineCompletionController, + getInlineCompletionController as getInlineCompletionControllerFromCore, +} from "@pen/core"; +import { + buildDocumentWriteOps, + getDocumentToolRuntime, +} from "@pen/document-ops"; +import type { + Decoration, + DocumentOp, + Editor, + Extension, + HistoryAppliedEvent, + KeyBinding, + ModelAdapter, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + OpOrigin, + SelectionState, + StreamingTarget, + TextSelection, + ToolDefinition, + ToolRuntime, + UndoHistoryMetadataController, +} from "@pen/types"; +import { + AI_AUTOCOMPLETE_CONTROLLER_SLOT, + AI_CONTROLLER_SLOT as CORE_AI_CONTROLLER_SLOT, + AI_INLINE_HISTORY_SLOT as CORE_AI_INLINE_HISTORY_SLOT, + AI_REVIEW_CONTROLLER_SLOT as CORE_AI_REVIEW_CONTROLLER_SLOT, + INLINE_COMPLETION_SLOT as CORE_INLINE_COMPLETION_SLOT, + defineExtension, + getOpOriginType, + isScopedSelectionTarget, + renderSelectionTargetBlockText, + resolveSelectionTargetBlockIds, + shouldExposeBlockInTooling, + UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { runAgenticLoop } from "../agentic/loop"; +import { defaultAICommands } from "../commands/defaultCommands"; +import { AICommandRegistry } from "../commands/registry"; +import { AIInlineHistoryService, AIReviewService } from "../controllers"; +import { buildAffectedRangeDecorations } from "../decorations/affectedRange"; +import { buildGenerationZoneDecorations } from "../decorations/generationZone"; +import { buildTrackChangesDecorations } from "../decorations/trackChanges"; +import { getBlockAdapter } from "../runtime/blockAdapters"; +import type { + AIApplyStrategy, + AIContentFormat, + AITargetKind, +} from "../runtime/contracts"; +import { resolveDocumentInsertionAnchor } from "../runtime/documentInsertionAnchor"; +import { + MARKDOWN_FAST_APPLY_ROOT_TAG, + normalizeFlowMarkdownOutput, +} from "../runtime/flowMarkdown"; +import { + applyMarkdownFastApply, + parseMarkdownFastApplyContract, +} from "../runtime/markdownFastApply"; +import { parseMarkdownPatchPlanContract } from "../runtime/markdownPatchPlan"; +import { buildMutationReceipt } from "../runtime/mutationReceipt"; +import { buildDocumentMutationPlanExecution } from "../runtime/planExecutor"; +import { validateDocumentMutationPlanShape } from "../runtime/planValidation"; +import type { StructuralReviewItem } from "../runtime/reviewArtifacts"; +import { + buildStructuralReviewItems, + removeStructuralReviewItemPlan, + selectStructuralReviewItemPlan, +} from "../runtime/reviewArtifacts"; +import { + classifyPromptIntent, + refineRouteWithNavigator, + routeAIRequest, +} from "../runtime/router"; +import { + isClearDocumentPrompt, + isDocumentFollowUpEditPrompt, + isDocumentResetPrompt, + isWholeDocumentRewritePrompt, +} from "../runtime/promptTargeting"; +import { SuggestedAIOperationRunner } from "../runtime/suggestedOperationRunner"; +import { compileStructuredIntentToPlan } from "../runtime/structuredIntentCompiler"; +import { + buildPlannerPrompt, + parseStructuredPlanPreview, + parseStructuredPlanResult, + resolveExecutionMode, +} from "../runtime/structuredPlanner"; +import { + buildGenerationStructuredPreviewState, + buildStructuredPreviewPatchOperations, +} from "../runtime/structuredPreview"; +import { + acceptAllSuggestions, + acceptSuggestion, + acceptSuggestions, + rejectAllSuggestions, + rejectSuggestion, + rejectSuggestions, +} from "../suggestions/acceptReject"; +import { readAllSuggestions } from "../suggestions/persistent"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestMode, + shouldBypassSuggestMode, + SUGGESTION_RESOLUTION_ORIGIN, +} from "../suggestions/suggestMode"; +import type { + AICommandBinding, + AICommandContext, + AICommandExecutionOptions, + AIContextualPromptRect, + AIController, + AIControllerState, + AIExtensionConfig, + AIInlineCompletionController, + AIInlineHistoryController, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, + AIMutationReceipt, + AIReviewController, + AIRequestedOperation, + AISession, + AISessionMetrics, + AISessionResolution, + AISessionSelectionSnapshot, + AISessionTarget, + AIStreamEvent, + AISurface, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + FastApplyDebugState, + GenerationState, + GenerationStructuredPreviewState, + PersistentTextSuggestion, + PersistentSuggestion, + ResolvedEditProposal, + ResolvedEditTarget, +} from "../types"; +import { resolveGenerationRequestMode, isLocalRequestedOperation, EMPTY_TOOL_RUNTIME, MAX_STREAM_EVENTS, AI_UNDO_HISTORY_METADATA_KEY, resolveOrderedReviewItems, sortReviewItemsForRemoval, compareReviewItemRemovalOrder, resolveActiveBlockId, readModelId, supportsStructuredIntent, createAIStreamEvent, resolvePromptTarget, resolveSessionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot } from "./extensionHelpersPart1"; +import type { GenerationTarget, GenerationExecutionContext, AIInlineHistoryRestoreRequest, AIInlineShortcutHistoryPhase, AIInlineShortcutHistoryState, AIInlineShortcutHistoryWaypoint, AIStreamEventInput } from "./extensionHelpersPart1"; +import { resolveContextualPromptAnchor, resolveContextualPromptState, createInlineHistorySnapshot, cloneSessionTarget, cloneInlineHistorySessions, recreateTextSelection, resolveSelectionSnapshotBlockRange, resolveSelectionSnapshotRangeStart, resolveSelectionSnapshotRangeEnd } from "./extensionHelpersPart2"; +import { resolveRequestedOperationForSession, resolveLocalOperationContentFormat, canUseLocalBlockTextOperation, canReuseBottomChatSessionOperation, resolveResolvedEditTargetFromRequestedOperation, areResolvedEditTargetsEqual, buildSessionExecutionPrompt } from "./extensionHelpersPart3"; +import { createRewriteSelectionOperation, createRewriteSelectionOperationFromResolvedTarget, createRewriteBlockOperation, createContinueBlockOperation, createDocumentTransformOperation, resolvePreviousGeneratedBlockIds, shouldReplacePreviousGeneratedBlocks, resolveReplacementDeleteBlockIds, createResolvedSelectionEditTarget, createResolvedScopedEditTarget, createResolvedEditProposal } from "./extensionHelpersPart4"; +import { resolveResolvedEditProposal, resolveSelectionForRequestedOperation, resolveFullBlockTextSelection, resolveDocumentBlockRangeSelection, resolveDocumentTitleSelection, resolveDocumentParagraphSelection, parseParagraphReference, resolveWordOrdinal, resolveBlockIdForRequestedOperation } from "./extensionHelpersPart5"; +import { resolveRequestedOperationConflict, resolveContinueInsertionOffset, createSelectionSignature, resolveSessionSelectionTarget, resolveLiveInlineSelectionTarget, resolvePendingInlineSelectionTarget, resolveAcceptedInlineSelectionTarget, shouldCloseInlineSessionPrompt, closeInlineSessionPrompt, createDefaultSessionFastApplyMetrics, accumulateSessionFastApplyMetrics, selectionMatchesSnapshot } from "./extensionHelpersPart6"; +import { resolveSessionSelectionSnapshots, sessionTargetMatches, sessionSelectionMatches, resolveSessionBlockId, resolveBlockInsertionOffset, appendUniqueString, areSuggestionsEqual, areAIControllerStatesEqual, areGenerationsEqual, areSessionsEqual, areInlineHistorySnapshotsEqual, didInlineHistoryCheckpointChange } from "./extensionHelpersPart7"; +import { buildInlineHistoryCheckpoint, countSettledInlineTurns, hasStreamingInlineTurns, resolveInlineShortcutHistoryState, areInlineShortcutHistoryStatesEqual, shouldReplaceInlineShortcutWaypointRepresentative, areEphemeralSuggestionsEqual, areStringArraysEqual, areStructuredValuesEqual } from "./extensionHelpersPart8"; + +export function buildSelectionReplacementOps( + editor: Editor, + selection: TextSelection, + insertedText: string, +): DocumentOp[] { + const range = selection.toRange(); + if (range.start.blockId === range.end.blockId) { + return [ + { + type: "replace-text", + blockId: range.start.blockId, + offset: range.start.offset, + length: range.end.offset - range.start.offset, + text: insertedText, + }, + ]; + } + const startId = range.start.blockId; + const endId = range.end.blockId; + const startText = editor.getBlock(startId)?.textContent() ?? ""; + const middleIds = range.blockRange.slice(1, -1); + const suffixDeltas = sliceInlineDeltasFromOffset( + editor.getBlock(endId)?.textDeltas() ?? [], + range.end.offset, + ); + const ops: DocumentOp[] = []; + + if (range.start.offset < startText.length) { + ops.push({ + type: "delete-text", + blockId: startId, + offset: range.start.offset, + length: startText.length - range.start.offset, + }); + } + + if (range.end.offset > 0) { + ops.push({ + type: "delete-text", + blockId: endId, + offset: 0, + length: range.end.offset, + }); + } + + for (const blockId of middleIds) { + ops.push({ + type: "delete-block", + blockId, + }); + } + + let insertionOffset = range.start.offset; + if (insertedText.length > 0) { + ops.push({ + type: "insert-text", + blockId: startId, + offset: insertionOffset, + text: insertedText, + }); + insertionOffset += insertedText.length; + } + + for (const delta of suffixDeltas) { + ops.push({ + type: "insert-text", + blockId: startId, + offset: insertionOffset, + text: delta.insert, + marks: delta.attributes, + }); + insertionOffset += delta.insert.length; + } + + ops.push({ + type: "delete-block", + blockId: endId, + }); + return ops; +} + +export function sliceInlineDeltasFromOffset( + deltas: readonly { insert: string; attributes?: Record }[], + startOffset: number, +): Array<{ insert: string; attributes?: Record }> { + const sliced: Array<{ + insert: string; + attributes?: Record; + }> = []; + let offset = 0; + for (const delta of deltas) { + const length = delta.insert.length; + if (startOffset >= offset + length) { + offset += length; + continue; + } + const localStart = Math.max(0, startOffset - offset); + const text = delta.insert.slice(localStart); + if (text.length > 0) { + sliced.push( + delta.attributes + ? { insert: text, attributes: delta.attributes } + : { insert: text }, + ); + } + offset += length; + } + return sliced; +} + +export function resolveSelectionText( + editor: Editor, + selection: TextSelection, +): string { + const range = selection.toRange(); + const blockIds = range.blockRange; + const parts = blockIds.map((blockId, index) => { + const block = editor.getBlock(blockId); + if (!block) return ""; + + let rawOffset = 0; + let resolved = ""; + const startOffset = index === 0 ? range.start.offset : 0; + const endOffset = + index === blockIds.length - 1 + ? range.end.offset + : Number.POSITIVE_INFINITY; + + for (const delta of block.textDeltas()) { + const length = delta.insert.length; + const rawStart = rawOffset; + const rawEnd = rawOffset + length; + rawOffset = rawEnd; + + if (endOffset <= rawStart || startOffset >= rawEnd) { + continue; + } + + const sliceStart = Math.max(0, startOffset - rawStart); + const sliceEnd = Math.min(length, endOffset - rawStart); + if (sliceEnd <= sliceStart) { + continue; + } + + const suggestion = delta.attributes?.suggestion as + | { action?: string } + | undefined; + if (suggestion?.action === "delete") { + continue; + } + + resolved += delta.insert.slice(sliceStart, sliceEnd); + } + + return resolved; + }); + + return parts.join("\n"); +} + +export function shouldReplaceEmptyMarkdownTarget( + block: ReturnType, +): boolean { + if (!block) { + return false; + } + + return ( + block.type === "paragraph" && + isVisuallyEmptyInlineText(block.textContent({ resolved: true })) + ); +} + +export function shouldTrimLeadingBlankBlockGenerationText( + block: ReturnType, +): boolean { + if (!block) { + return false; + } + return isVisuallyEmptyInlineText(block.textContent({ resolved: true })); +} + +export function trimLeadingBlankBlockGenerationText(text: string): string { + return text.replace(/^(?:[ \t]*\r?\n)+/, ""); +} + +export function isVisuallyEmptyInlineText(text: string): boolean { + return text.replace(/\u200B/g, "").trim().length === 0; +} diff --git a/packages/extensions/ai/src/extensionParts/generationExecution.ts b/packages/extensions/ai/src/extensionParts/generationExecution.ts new file mode 100644 index 0000000..2267c49 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/generationExecution.ts @@ -0,0 +1,364 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { getDocumentToolRuntime, EMPTY_TOOL_RUNTIME, isLocalRequestedOperation, buildSessionExecutionPrompt, routeAIRequest, getBlockAdapter, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, supportsStructuredIntent, buildPlannerPrompt, resolveExecutionMode, createDefaultSessionFastApplyMetrics, createAIStreamEvent, resolveGenerationRequestMode, trimLeadingBlankBlockGenerationText, parseStructuredPlanPreview, buildGenerationStructuredPreviewState, areStructuredValuesEqual, buildStructuredPreviewPatchOperations, compileStructuredIntentToPlan, parseStructuredPlanResult, buildDocumentMutationPlanExecution, buildStructuralReviewItems, resolvePendingInlineSelectionTarget, resolveLiveInlineSelectionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, appendUniqueString } = deps; + +import { runGenerationLoop } from "./generationExecutionLoop"; +import { finalizeGenerationExecution, handleGenerationExecutionError } from "./generationExecutionFinalize"; + +export async function executeGeneration(controller: any, input: any): Promise { + const { prompt, target, commandId, maxSteps, context } = input; + if (!controller._model) { + throw new Error("No AI model configured"); + } + + controller.cancelActiveGeneration(); + const toolRuntime = + getDocumentToolRuntime(controller._editor) ?? EMPTY_TOOL_RUNTIME; + const abortController = new AbortController(); + controller._abortController = abortController; + + const baselineSuggestionIds = new Set( + controller.getSuggestions().map((item) => item.id), + ); + const blockId = + target.type === "block" + ? target.blockId + : target.selection.toRange().start.blockId; + const requestedOperation = context?.operation ?? null; + if ( + context?.surface === "bottom-chat" && + isLocalRequestedOperation(requestedOperation) + ) { + return controller._executeLocalOperation({ + prompt, + target, + blockId, + commandId, + context, + abortController, + baselineSuggestionIds, + operation: requestedOperation, + }); + } + const requestedContentFormat = controller._resolveContentFormat( + target.type, + context?.surface, + ); + let route = routeAIRequest({ + prompt, + selection: controller._editor.selection, + blockType: controller._editor.getBlock(blockId)?.type ?? null, + blockCount: controller._editor.blockCount(), + suggestMode: controller._state.suggestMode, + target: target.type, + contentFormat: requestedContentFormat, + surface: context?.surface, + }); + let workingSet = await controller._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ); + const refinedRoute = controller._refineRouteWithWorkingSet(route, workingSet); + if (refinedRoute.lane !== route.lane) { + route = refinedRoute; + workingSet = await controller._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ); + } else { + route = refinedRoute; + } + const adapter = getBlockAdapter(route.adapterId); + const contentFormat = route.contentFormat; + let currentText = ""; + const streamingTarget = + controller._editor.internals.getSlot( + "delta-stream:target", + ) ?? null; + let blockStreamingStarted = false; + const shouldStreamDirectly = route.shouldStreamDirectly; + const selectionRange = + target.type === "selection" ? target.selection.toRange() : null; + const selectionSourceText = + target.type === "selection" + ? resolveSelectionText(controller._editor, target.selection) + : ""; + const shouldStreamSuggestedText = + route.mutationMode === "streaming-suggestions" && + route.plannerMode !== "structured" && + contentFormat === "text"; + const shouldReplaceMarkdownTarget = + context?.replaceTargetBlock === true || + (route.plannerMode !== "structured" && + contentFormat === "markdown" && + target.type === "block" && + (route.targetKind === "table" || + (context?.surface === "bottom-chat" && + shouldReplaceEmptyMarkdownTarget( + controller._editor.getBlock(blockId), + )))); + const canStreamSelectionSuggestions = + shouldStreamSuggestedText && + target.type === "selection" && + selectionRange?.start.blockId === selectionRange?.end.blockId; + const canStreamBlockSuggestions = + shouldStreamSuggestedText && target.type === "block"; + const canStreamMarkdownBlockSuggestions = + route.mutationMode === "streaming-suggestions" && + route.plannerMode !== "structured" && + contentFormat === "markdown" && + target.type === "block" && + route.applyStrategy === "markdown-full-replace" && + context?.surface === "bottom-chat"; + let streamedSuggestionInitialized = false; + let streamedSuggestionLength = 0; + let streamedMarkdownSuggestionIds: string[] = []; + let lastStreamedMarkdownPreviewText = ""; + const sessionTurnId = context?.sessionId + ? crypto.randomUUID() + : undefined; + const existingSession = + context?.sessionId != null + ? (controller._state.sessions.find( + (session) => session.id === context.sessionId, + ) ?? null) + : null; + const executionPrompt = buildSessionExecutionPrompt( + existingSession, + prompt, + ); + let shouldTrimLeadingBlankBlockText = + target.type === "block" && + shouldTrimLeadingBlankBlockGenerationText( + controller._editor.getBlock(blockId), + ); + const useStructuredIntentTransport = + adapter.transportKind !== "flow-text" && + supportsStructuredIntent(controller._model); + const generationPrompt = + useStructuredIntentTransport || + (adapter.id === "flow-markdown" && contentFormat === "markdown") + ? adapter.buildPrompt({ + prompt: executionPrompt, + targetKind: route.targetKind, + activeBlockId: blockId, + workingSet, + applyStrategy: route.applyStrategy, + }) + : route.plannerMode === "structured" + ? buildPlannerPrompt({ + prompt: executionPrompt, + targetKind: route.targetKind, + workingSet, + }) + : executionPrompt; + + const seedGeneration: GenerationState = { + id: crypto.randomUUID(), + zoneId: crypto.randomUUID(), + blockId, + target: target.type, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + prompt, + operation: requestedOperation, + status: "streaming", + tokenCount: 0, + steps: [], + undoGroupId: crypto.randomUUID(), + text: "", + commandId, + suggestionIds: [], + route: route.lane, + mutationMode: route.mutationMode, + contentFormat, + applyStrategy: route.applyStrategy, + planState: "none", + plan: null, + structuredIntent: null, + reviewItems: [], + structuredPreview: null, + targetKind: route.targetKind, + blockClass: route.blockClass, + adapterId: route.adapterId, + transportKind: route.transportKind, + mutationReceipt: null, + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + routeConfidence: workingSet?.routeConfidence, + structured: { + plannerMode: route.plannerMode, + executionMode: resolveExecutionMode(route.mutationMode), + targetKind: route.targetKind, + validationIssueCount: 0, + }, + fastApply: { + attempted: false, + succeeded: false, + }, + }, + }; + if (context?.sessionId) { + const nextSelectionSnapshot = + target.type === "selection" + ? resolveSessionSelectionSnapshot(target.selection) + : undefined; + controller._updateSession(context.sessionId, { + status: "streaming", + operation: requestedOperation, + activeTurnId: sessionTurnId, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : resolveSessionAnchor(controller._editor.selection), + generationIds: appendUniqueString( + existingSession?.generationIds ?? [], + seedGeneration.id, + ), + promptHistory: [ + ...(existingSession?.promptHistory ?? []), + { + id: crypto.randomUUID(), + prompt, + createdAt: Date.now(), + generationId: seedGeneration.id, + operation: requestedOperation ?? undefined, + }, + ], + turns: sessionTurnId + ? [ + ...(existingSession?.turns ?? []), + { + id: sessionTurnId, + prompt, + createdAt: Date.now(), + undoGroupId: seedGeneration.undoGroupId, + generationId: seedGeneration.id, + target: target.type, + operation: requestedOperation ?? undefined, + status: "streaming", + suggestionIds: [], + reviewItemIds: [], + generatedBlockIds: [], + structuredPreview: null, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : undefined, + selection: + target.type === "selection" + ? resolveSessionSelectionSnapshot( + target.selection, + ) + : undefined, + }, + ] + : existingSession?.turns, + contextualPrompt: existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: + target.type === "selection" + ? { + ...existingSession.contextualPrompt + .anchor, + selectionSnapshot: + nextSelectionSnapshot, + focusBlockId: + target.selection.toRange().start + .blockId, + status: "valid", + } + : existingSession.contextualPrompt.anchor, + composer: { + ...existingSession.contextualPrompt.composer, + draftPrompt: "", + isSubmitting: true, + isOpen: true, + openReason: "user", + }, + } + : undefined, + }); + } + controller._setState({ + status: "thinking", + activeGeneration: seedGeneration, + commandMenuOpen: false, + lastRoute: route.lane, + activeSessionId: context?.sessionId ?? controller._state.activeSessionId, + }); + let currentStructuredPreview: GenerationStructuredPreviewState | null = + null; + let currentStructuredIntent: GenerationState["structuredIntent"] = null; + let currentMutationReceipt: AIMutationReceipt | null = null; + controller._setStreamEvents([ + createAIStreamEvent(seedGeneration, { + type: "generation-start", + prompt, + target: target.type, + }), + createAIStreamEvent(seedGeneration, { + type: "status", + status: "thinking", + }), + ]); + const state = { + prompt, + target, + commandId, + maxSteps, + context, + toolRuntime, + abortController, + baselineSuggestionIds, + blockId, + requestedOperation, + route, + workingSet, + adapter, + contentFormat, + currentText, + streamingTarget, + blockStreamingStarted, + shouldStreamDirectly, + selectionRange, + selectionSourceText, + shouldReplaceMarkdownTarget, + canStreamSelectionSuggestions, + canStreamBlockSuggestions, + canStreamMarkdownBlockSuggestions, + streamedSuggestionInitialized, + streamedSuggestionLength, + streamedMarkdownSuggestionIds, + lastStreamedMarkdownPreviewText, + sessionTurnId, + existingSession, + executionPrompt, + shouldTrimLeadingBlankBlockText, + useStructuredIntentTransport, + generationPrompt, + seedGeneration, + currentStructuredPreview, + currentStructuredIntent, + currentMutationReceipt, + }; + try { + const result = await runGenerationLoop(controller, state); + return finalizeGenerationExecution(controller, state, result); + } catch (error) { + return handleGenerationExecutionError(controller, state, error); + } +} diff --git a/packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts b/packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts new file mode 100644 index 0000000..763ae00 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/generationExecutionFinalize.ts @@ -0,0 +1,367 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { getDocumentToolRuntime, EMPTY_TOOL_RUNTIME, isLocalRequestedOperation, routeAIRequest, getBlockAdapter, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, supportsStructuredIntent, buildPlannerPrompt, resolveExecutionMode, createDefaultSessionFastApplyMetrics, createAIStreamEvent, resolveGenerationRequestMode, trimLeadingBlankBlockGenerationText, parseStructuredPlanPreview, buildGenerationStructuredPreviewState, areStructuredValuesEqual, buildStructuredPreviewPatchOperations, compileStructuredIntentToPlan, parseStructuredPlanResult, buildDocumentMutationPlanExecution, buildStructuralReviewItems, resolvePendingInlineSelectionTarget, resolveLiveInlineSelectionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, appendUniqueString } = deps; + +export function finalizeGenerationExecution(controller: any, state: any, result: any): any { + const { target, canStreamSelectionSuggestions, route, context, selectionSourceText, seedGeneration, contentFormat, shouldStreamDirectly, canStreamBlockSuggestions, canStreamMarkdownBlockSuggestions, workingSet, useStructuredIntentTransport, adapter, blockId, requestedOperation, sessionTurnId, commandId, baselineSuggestionIds, shouldReplaceMarkdownTarget } = state; + if ( + target.type === "selection" && + state.currentText.length > 0 && + !canStreamSelectionSuggestions + ) { + state.currentMutationReceipt = controller._commitSelectionRewrite( + target.selection, + state.currentText, + route.mutationMode, + context?.sessionId, + ); + controller._inlineCompletion.dismissSuggestion(); + } else if ( + target.type === "selection" && + state.currentText.length > 0 && + canStreamSelectionSuggestions + ) { + controller._recordFastApplyDebug({ + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + contextChars: selectionSourceText.length, + diffChars: state.currentText.length, + }); + } else if ( + target.type === "block" && + state.currentText.length > 0 && + !shouldStreamDirectly && + !canStreamBlockSuggestions && + !canStreamMarkdownBlockSuggestions && + route.plannerMode !== "structured" + ) { + state.currentMutationReceipt = controller._commitBufferedBlockGeneration( + target.blockId, + state.currentText, + route.mutationMode, + contentFormat, + context?.sessionId, + { + applyStrategy: route.applyStrategy, + insertionOffset: target.offset, + workingSet, + replaceTargetBlock: shouldReplaceMarkdownTarget, + replaceBlockIds: context?.replaceBlockIds, + }, + ); + controller._inlineCompletion.dismissSuggestion(); + } + + const suggestionIds = controller.getSuggestions() + .map((item) => item.id) + .filter((id) => !baselineSuggestionIds.has(id)); + const structuredPlanResult = + route.plannerMode === "structured" && + !useStructuredIntentTransport + ? parseStructuredPlanResult(state.currentText, route.targetKind) + : null; + const structuredIntentResolution = useStructuredIntentTransport + ? (adapter.resolveResult?.({ + value: state.currentStructuredIntent, + targetKind: route.targetKind, + activeBlockId: blockId, + }) ?? null) + : null; + const structuredIntentResult = + structuredIntentResolution?.parseResult ?? null; + const structuredIntentCompilation = + structuredIntentResolution?.compilation ?? null; + const resolvedStructuredPlan = + structuredIntentCompilation?.plan ?? + structuredPlanResult?.plan ?? + null; + const planExecution = resolvedStructuredPlan + ? buildDocumentMutationPlanExecution( + controller._editor, + resolvedStructuredPlan, + ) + : null; + const reviewItems = + resolvedStructuredPlan && + route.mutationMode !== "direct-stream" && + (!planExecution || !planExecution.reviewSafe) + ? buildStructuralReviewItems( + controller._editor, + resolvedStructuredPlan, + ) + : []; + + if ( + resolvedStructuredPlan && + planExecution && + planExecution.issues.length === 0 + ) { + state.currentMutationReceipt = controller._commitStructuredPlan( + planExecution.ops, + planExecution.reviewSafe, + route.mutationMode, + route.adapterId, + route.blockClass, + route.transportKind, + ); + } + if (!state.currentMutationReceipt) { + state.currentMutationReceipt = controller._buildFallbackMutationReceipt({ + currentText: state.currentText, + suggestionIds, + reviewItems, + planExecutionIssueCount: planExecution?.issues.length ?? 0, + adapterId: route.adapterId, + blockClass: route.blockClass, + transportKind: route.transportKind, + }); + } + const structuredDebug = { + plannerMode: route.plannerMode, + executionMode: resolveExecutionMode(route.mutationMode), + targetKind: route.targetKind, + validationIssueCount: + (structuredPlanResult?.issues.length ?? 0) + + (structuredIntentResult?.issues.length ?? 0) + + (structuredIntentCompilation?.issues.length ?? 0) + + (planExecution?.issues.length ?? 0), + }; + const resolvedDebug = + controller._state.activeGeneration?.id === seedGeneration.id + ? (controller._state.activeGeneration.debug ?? + result.debug ?? + seedGeneration.debug!) + : (result.debug ?? seedGeneration.debug!); + const resolvedPlanState: GenerationState["planState"] = + planExecution && planExecution.issues.length > 0 + ? "rejected" + : structuredIntentResult?.intentState === "validated" && + (structuredIntentCompilation?.issues.length ?? 0) === + 0 + ? "validated" + : structuredIntentResult?.intentState === "drafted" + ? "drafted" + : (structuredPlanResult?.planState ?? + seedGeneration.planState); + + const finalGeneration: GenerationState = { + ...result, + blockId, + target: target.type, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + commandId, + text: state.currentText, + suggestionIds, + route: route.lane, + mutationMode: route.mutationMode, + contentFormat, + planState: resolvedPlanState, + plan: resolvedStructuredPlan, + structuredIntent: + structuredIntentResult?.intent ?? + state.currentStructuredIntent ?? + null, + reviewItems, + structuredPreview: resolvedStructuredPlan + ? buildGenerationStructuredPreviewState(controller._editor, { + planState: + planExecution && + planExecution.issues.length === 0 + ? "validated" + : "drafted", + plan: resolvedStructuredPlan, + }) + : state.currentStructuredPreview, + targetKind: route.targetKind, + blockClass: route.blockClass, + adapterId: route.adapterId, + transportKind: route.transportKind, + mutationReceipt: state.currentMutationReceipt, + debug: { + ...resolvedDebug, + structured: structuredDebug, + }, + }; + controller._abortController = null; + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "generation-finish", + status: finalGeneration.status, + text: state.currentText, + }), + ); + controller._setState({ + status: "idle", + activeGeneration: finalGeneration, + }); + if (context?.sessionId) { + const structuredPreviewEvents = controller.getStreamEvents().filter( + (event) => + event.type === "structured-preview" && + event.sessionId === context.sessionId, + ); + const lastStructuredPreviewEvent = + structuredPreviewEvents[structuredPreviewEvents.length - 1]; + const refreshedInlineReviewSelectionTarget = + context?.surface === "inline-edit" && + suggestionIds.length > 0 + ? (resolvePendingInlineSelectionTarget( + controller._editor, + requestedOperation ?? undefined, + suggestionIds, + ) ?? resolveLiveInlineSelectionTarget(controller._editor)) + : null; + if (sessionTurnId) { + const receiptEvidence = state.currentMutationReceipt?.evidence; + const generatedBlockIds = receiptEvidence + ? [ + ...new Set([ + ...receiptEvidence.affectedBlockIds, + ...receiptEvidence.createdBlockIds, + ]), + ] + : []; + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: + suggestionIds.length > 0 || reviewItems.length > 0 + ? "review" + : finalGeneration.status === "complete" + ? "complete" + : finalGeneration.status, + suggestionIds, + reviewItemIds: reviewItems.map((item) => item.id), + generatedBlockIds, + structuredPreview: + finalGeneration.structuredPreview ?? null, + anchor: refreshedInlineReviewSelectionTarget + ? resolveSessionAnchor( + refreshedInlineReviewSelectionTarget.selection, + ) + : undefined, + selection: refreshedInlineReviewSelectionTarget + ? resolveSessionSelectionSnapshot( + refreshedInlineReviewSelectionTarget.selection, + ) + : undefined, + }); + } + const resolvedGenerationDebug = + controller._state.activeGeneration?.id === finalGeneration.id + ? controller._state.activeGeneration.debug + : finalGeneration.debug; + controller._recordSessionFastApplyMetrics( + context.sessionId, + resolvedGenerationDebug?.fastApply, + ); + controller._updateSession(context.sessionId, { + status: + finalGeneration.status === "complete" + ? "complete" + : finalGeneration.status, + pendingSuggestionIds: suggestionIds, + pendingReviewItemIds: reviewItems.map((item) => item.id), + metrics: { + ...(controller._state.sessions.find( + (session) => session.id === context.sessionId, + )?.metrics ?? { + streamEventCount: 0, + patchCount: 0, + fastApply: createDefaultSessionFastApplyMetrics(), + }), + firstTokenMs: + resolvedGenerationDebug?.firstVisibleTextMs ?? + undefined, + totalMs: + resolvedGenerationDebug?.messageAssemblyLatencyMs != + null + ? resolvedGenerationDebug.messageAssemblyLatencyMs + + (resolvedGenerationDebug.toolExecutionMs ?? + 0) + : undefined, + toolMs: + resolvedGenerationDebug?.toolExecutionMs ?? + undefined, + streamEventCount: controller._streamEvents.filter( + (event) => event.sessionId === context.sessionId, + ).length, + patchCount: + lastStructuredPreviewEvent?.type === + "structured-preview" + ? lastStructuredPreviewEvent.patches.length + : 0, + }, + }); + } + + if (finalGeneration.status === "complete") { + controller._editor.internals.emit("diagnostic", { + level: "info", + source: "ai", + code: "GENERATION_COMPLETE", + message: "AI generation completed", + blockId, + generationId: finalGeneration.id, + }); + } + + return finalGeneration; +} + +export function handleGenerationExecutionError(controller: any, state: any, error: unknown): any { + const { seedGeneration, blockId, context, sessionTurnId, commandId, target, abortController, route, streamingTarget, prompt } = state; + const isStaleWorkingSet = + error instanceof Error && error.name === "StaleWorkingSetError"; + const failedGeneration: GenerationState = { + ...(controller._state.activeGeneration ?? seedGeneration), + blockId, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + prompt, + commandId, + text: state.currentText, + status: + abortController.signal.aborted || isStaleWorkingSet + ? "cancelled" + : "error", + targetKind: route.targetKind, + }; + controller._abortController = null; + controller._inlineCompletion.dismissSuggestion(); + if (target.type === "block" && state.blockStreamingStarted) { + streamingTarget?.endStreaming( + abortController.signal.aborted ? "cancelled" : "error", + ); + state.blockStreamingStarted = false; + } + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "generation-finish", + status: failedGeneration.status, + text: state.currentText, + }), + ); + controller._setState({ + status: "idle", + activeGeneration: failedGeneration, + }); + if (context?.sessionId) { + if (sessionTurnId) { + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: failedGeneration.status, + reviewItemIds: [], + structuredPreview: null, + }); + } + controller._updateSession(context.sessionId, { + status: failedGeneration.status, + }); + } + if (abortController.signal.aborted || isStaleWorkingSet) { + return failedGeneration; + } + throw error; +} diff --git a/packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts b/packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts new file mode 100644 index 0000000..7f70914 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/generationExecutionLoop.ts @@ -0,0 +1,384 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { getDocumentToolRuntime, EMPTY_TOOL_RUNTIME, isLocalRequestedOperation, routeAIRequest, getBlockAdapter, resolveSelectionText, shouldReplaceEmptyMarkdownTarget, shouldTrimLeadingBlankBlockGenerationText, supportsStructuredIntent, buildPlannerPrompt, resolveExecutionMode, createDefaultSessionFastApplyMetrics, createAIStreamEvent, resolveGenerationRequestMode, trimLeadingBlankBlockGenerationText, parseStructuredPlanPreview, buildGenerationStructuredPreviewState, areStructuredValuesEqual, buildStructuredPreviewPatchOperations, compileStructuredIntentToPlan, parseStructuredPlanResult, buildDocumentMutationPlanExecution, buildStructuralReviewItems, resolvePendingInlineSelectionTarget, resolveLiveInlineSelectionTarget, resolveSessionAnchor, resolveSessionSelectionSnapshot, appendUniqueString, runAgenticLoop } = deps; + +export async function runGenerationLoop(controller: any, state: any): Promise { + const { route, toolRuntime, generationPrompt, blockId, seedGeneration, maxSteps, target, shouldStreamDirectly, streamingTarget, selectionRange, canStreamSelectionSuggestions, canStreamBlockSuggestions, canStreamMarkdownBlockSuggestions, context, baselineSuggestionIds, shouldReplaceMarkdownTarget, useStructuredIntentTransport, adapter, abortController, workingSet, sessionTurnId } = state; + const result = await runAgenticLoop({ + model: controller._model, + editor: controller._editor, + toolRuntime: route.allowToolUse + ? toolRuntime + : EMPTY_TOOL_RUNTIME, + prompt: generationPrompt, + blockId, + generationId: seedGeneration.id, + zoneId: seedGeneration.zoneId, + maxSteps: route.allowToolUse + ? (maxSteps ?? controller._maxAgenticSteps) + : 1, + signal: abortController.signal, + requestMode: resolveGenerationRequestMode({ + ...context, + targetType: target.type, + }), + operation: context?.operation, + sessionId: context?.sessionId, + turnId: sessionTurnId, + workingSet, + validateWorkingSet: (activeWorkingSet) => + controller._validateWorkingSet(route, target, activeWorkingSet), + refreshWorkingSet: async () => + controller._buildWorkingSet( + toolRuntime, + route, + target, + blockId, + prompt, + ), + onStatusChange: (status) => { + controller._setState({ status }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "status", + status, + }), + ); + }, + onStep: (step) => { + const active = controller._state.activeGeneration; + if (!active) return; + controller._setState({ + activeGeneration: { + ...active, + steps: [...active.steps, step], + }, + }); + }, + onTextDelta: (delta) => { + const nextDelta = + target.type === "block" && + state.shouldTrimLeadingBlankBlockText + ? trimLeadingBlankBlockGenerationText(delta) + : delta; + if ( + state.shouldTrimLeadingBlankBlockText && + nextDelta.length > 0 + ) { + state.shouldTrimLeadingBlankBlockText = false; + } + if (nextDelta.length === 0) { + return; + } + state.currentText += nextDelta; + if (target.type === "block" && shouldStreamDirectly) { + streamingTarget?.appendDelta(nextDelta); + } else if ( + canStreamSelectionSuggestions && + selectionRange + ) { + if (!state.streamedSuggestionInitialized) { + controller._applySuggestedAIOps( + [ + { + type: "replace-text", + blockId: selectionRange.start.blockId, + offset: selectionRange.start.offset, + length: + selectionRange.end.offset - + selectionRange.start.offset, + text: nextDelta, + }, + ], + context?.sessionId, + { undoGroupId: seedGeneration.undoGroupId }, + ); + state.streamedSuggestionInitialized = true; + state.streamedSuggestionLength = nextDelta.length; + } else if (nextDelta.length > 0) { + controller._applySuggestedAIOps( + [ + { + type: "insert-text", + blockId: selectionRange.start.blockId, + offset: + selectionRange.end.offset + + state.streamedSuggestionLength, + text: nextDelta, + }, + ], + context?.sessionId, + { undoGroupId: seedGeneration.undoGroupId }, + ); + state.streamedSuggestionLength += nextDelta.length; + } + } else if ( + canStreamBlockSuggestions && + target.type === "block" + ) { + if (nextDelta.length > 0) { + controller._applySuggestedAIOps( + [ + { + type: "insert-text", + blockId: target.blockId, + offset: + target.offset + + state.streamedSuggestionLength, + text: nextDelta, + }, + ], + context?.sessionId, + { undoGroupId: seedGeneration.undoGroupId }, + ); + state.streamedSuggestionLength += nextDelta.length; + } + } else if ( + canStreamMarkdownBlockSuggestions && + target.type === "block" + ) { + const previewRefresh = + controller._refreshStreamingMarkdownBlockPreview( + target.blockId, + state.currentText, + route.mutationMode, + context?.sessionId, + baselineSuggestionIds, + state.streamedMarkdownSuggestionIds, + state.lastStreamedMarkdownPreviewText, + shouldReplaceMarkdownTarget, + context?.replaceBlockIds, + ); + state.streamedMarkdownSuggestionIds = + previewRefresh.suggestionIds; + state.lastStreamedMarkdownPreviewText = + previewRefresh.normalizedText; + } else if (target.type === "selection") { + controller._inlineCompletion.showSuggestion({ + id: seedGeneration.id, + blockId: blockId, + offset: target.selection.toRange().start.offset, + text: state.currentText, + type: "inline", + }); + } + const active = controller._state.activeGeneration; + if (!active) return; + controller._setState({ + activeGeneration: { + ...active, + text: state.currentText, + status: "streaming", + }, + }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "text-delta", + delta: nextDelta, + text: state.currentText, + }), + ); + if ( + route.plannerMode === "structured" && + !useStructuredIntentTransport + ) { + const previewResult = parseStructuredPlanPreview( + state.currentText, + route.targetKind, + ); + if (previewResult?.plan) { + const nextStructuredPreview = + buildGenerationStructuredPreviewState( + controller._editor, + { + planState: + previewResult.planState === + "validated" + ? "validated" + : "drafted", + plan: previewResult.plan, + }, + ); + if ( + !areStructuredValuesEqual( + state.currentStructuredPreview, + nextStructuredPreview, + ) + ) { + const patches = + buildStructuredPreviewPatchOperations( + state.currentStructuredPreview, + nextStructuredPreview, + ); + state.currentStructuredPreview = + nextStructuredPreview; + controller._resolveActiveGeneration({ + structuredPreview: nextStructuredPreview, + }); + if (context?.sessionId && sessionTurnId) { + controller._updateSessionTurn( + context.sessionId, + sessionTurnId, + { + reviewItemIds: + nextStructuredPreview.reviewItems.map( + (item) => item.id, + ), + structuredPreview: + nextStructuredPreview, + }, + ); + } + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "structured-preview", + preview: nextStructuredPreview, + patches, + }), + ); + } + } + } + }, + onStructuredData: (event) => { + if (!useStructuredIntentTransport) { + return; + } + const previewResult = + adapter.parsePreview?.({ + value: event.data, + targetKind: route.targetKind, + activeBlockId: blockId, + }) ?? null; + if (!previewResult?.intent) { + return; + } + state.currentStructuredIntent = previewResult.intent; + const compilation = compileStructuredIntentToPlan( + previewResult.intent, + { + activeBlockId: blockId, + }, + ); + if (!compilation.plan) { + return; + } + const nextStructuredPreview = + buildGenerationStructuredPreviewState(controller._editor, { + planState: + previewResult.intentState === "validated" && + compilation.issues.length === 0 + ? "validated" + : "drafted", + plan: compilation.plan, + }); + if ( + areStructuredValuesEqual( + state.currentStructuredPreview, + nextStructuredPreview, + ) + ) { + return; + } + const patches = buildStructuredPreviewPatchOperations( + state.currentStructuredPreview, + nextStructuredPreview, + ); + state.currentStructuredPreview = nextStructuredPreview; + controller._resolveActiveGeneration({ + structuredIntent: previewResult.intent, + structuredPreview: nextStructuredPreview, + }); + if (context?.sessionId && sessionTurnId) { + controller._updateSessionTurn( + context.sessionId, + sessionTurnId, + { + reviewItemIds: + nextStructuredPreview.reviewItems.map( + (item) => item.id, + ), + structuredPreview: nextStructuredPreview, + }, + ); + } + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "app-partial", + data: event.data, + final: event.final, + }), + ); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "structured-preview", + preview: nextStructuredPreview, + patches, + }), + ); + }, + onToolCall: (event) => { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + }), + ); + }, + onToolOutput: (event) => { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "tool-output", + toolCallId: event.toolCallId, + toolName: event.toolName, + part: event.part, + output: event.output, + }), + ); + }, + onToolResult: (event) => { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "tool-result", + toolCallId: event.toolCallId, + toolName: event.toolName, + output: event.output, + state: event.state, + }), + ); + }, + onDebug: (debug) => { + const active = controller._state.activeGeneration; + if (!active) return; + controller._setState({ + activeGeneration: { + ...active, + debug, + }, + }); + }, + onStreamingStart: (zoneId, targetBlockId) => { + if ( + target.type !== "block" || + !shouldStreamDirectly || + state.blockStreamingStarted + ) + return; + streamingTarget?.beginStreaming(zoneId, targetBlockId); + state.blockStreamingStarted = true; + }, + onStreamingEnd: (status) => { + if ( + target.type !== "block" || + !shouldStreamDirectly || + !state.blockStreamingStarted + ) + return; + streamingTarget?.endStreaming(status); + state.blockStreamingStarted = false; + }, + }); + return result; +} diff --git a/packages/extensions/ai/src/extensionParts/localOperationExecution.ts b/packages/extensions/ai/src/extensionParts/localOperationExecution.ts new file mode 100644 index 0000000..91fad70 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/localOperationExecution.ts @@ -0,0 +1,457 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { resolveLocalOperationContentFormat, buildSessionExecutionPrompt, resolveSessionSelectionSnapshot, resolveSessionAnchor, appendUniqueString, createAIStreamEvent, resolveGenerationRequestMode, buildMutationReceipt } = deps; +import { finalizeLocalOperationExecution } from "./localOperationExecutionFinalize"; + +export async function executeLocalOperation(controller: any, input: any): Promise { + const { + prompt, + target, + blockId, + commandId, + context, + abortController, + baselineSuggestionIds, + operation, + } = input; + const sessionTurnId = context?.sessionId + ? crypto.randomUUID() + : undefined; + const mutationMode: NonNullable = + "persistent-suggestions"; + const contentFormat = resolveLocalOperationContentFormat( + controller._editor, + operation, + controller._resolveContentFormat("block", context?.surface), + ); + const streamsMarkdownSelectionPreview = + operation.kind === "rewrite-selection" && + operation.target.kind === "scoped-range" && + contentFormat === "markdown" && + operation.target.blockIds.length > 0; + const applyStrategy: AIApplyStrategy | undefined = + (operation.kind === "rewrite-block" || + streamsMarkdownSelectionPreview || + (operation.kind === "document-transform" && + operation.target.kind === "document" && + (operation.target.placement === "replace-blocks" || + operation.target.placement === + "replace-empty-block"))) && + contentFormat === "markdown" + ? "markdown-full-replace" + : undefined; + const seedGeneration: GenerationState = { + id: crypto.randomUUID(), + zoneId: crypto.randomUUID(), + blockId, + target: target.type, + sessionId: context?.sessionId, + turnId: sessionTurnId, + surface: context?.surface, + prompt, + operation, + status: "streaming", + tokenCount: 0, + steps: [], + undoGroupId: crypto.randomUUID(), + text: "", + commandId, + suggestionIds: [], + route: + operation.kind === "rewrite-selection" + ? "selection-rewrite" + : operation.kind === "continue-block" + ? "cursor-context" + : "context-first", + mutationMode, + contentFormat, + applyStrategy, + planState: "none", + plan: null, + structuredIntent: null, + reviewItems: [], + structuredPreview: null, + targetKind: undefined, + blockClass: "flow", + adapterId: "flow-markdown", + transportKind: "flow-text", + mutationReceipt: null, + debug: { + messageAssemblyLatencyMs: 0, + firstToolStartMs: null, + firstToolResultMs: null, + firstVisibleTextMs: null, + toolExecutionMs: 0, + qualitySignals: {}, + }, + }; + const existingSession = + context?.sessionId != null + ? (controller._state.sessions.find( + (session) => session.id === context.sessionId, + ) ?? null) + : null; + const executionPrompt = buildSessionExecutionPrompt( + existingSession, + prompt, + ); + + if (context?.sessionId) { + const nextSelectionSnapshot = + target.type === "selection" + ? resolveSessionSelectionSnapshot(target.selection) + : undefined; + controller._updateSession(context.sessionId, { + status: "streaming", + operation, + activeTurnId: sessionTurnId, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : resolveSessionAnchor(controller._editor.selection), + generationIds: appendUniqueString( + existingSession?.generationIds ?? [], + seedGeneration.id, + ), + promptHistory: [ + ...(existingSession?.promptHistory ?? []), + { + id: crypto.randomUUID(), + prompt, + createdAt: Date.now(), + generationId: seedGeneration.id, + operation, + }, + ], + turns: sessionTurnId + ? [ + ...(existingSession?.turns ?? []), + { + id: sessionTurnId, + prompt, + createdAt: Date.now(), + undoGroupId: seedGeneration.undoGroupId, + generationId: seedGeneration.id, + target: target.type, + operation, + status: "streaming", + suggestionIds: [], + reviewItemIds: [], + generatedBlockIds: [], + structuredPreview: null, + anchor: + target.type === "selection" + ? resolveSessionAnchor(target.selection) + : undefined, + selection: + target.type === "selection" + ? resolveSessionSelectionSnapshot( + target.selection, + ) + : undefined, + }, + ] + : existingSession?.turns, + contextualPrompt: existingSession?.contextualPrompt + ? { + ...existingSession.contextualPrompt, + anchor: + target.type === "selection" + ? { + ...existingSession.contextualPrompt + .anchor, + selectionSnapshot: + nextSelectionSnapshot, + focusBlockId: + target.selection.toRange().start + .blockId, + status: "valid", + } + : existingSession.contextualPrompt.anchor, + composer: { + ...existingSession.contextualPrompt.composer, + draftPrompt: "", + isSubmitting: true, + isOpen: true, + openReason: "user", + }, + } + : undefined, + }); + } + + controller._setState({ + status: "thinking", + activeGeneration: seedGeneration, + commandMenuOpen: false, + lastRoute: seedGeneration.route, + activeSessionId: context?.sessionId ?? controller._state.activeSessionId, + }); + controller._setStreamEvents([ + createAIStreamEvent(seedGeneration, { + type: "generation-start", + prompt, + target: target.type, + }), + createAIStreamEvent(seedGeneration, { + type: "status", + status: "thinking", + }), + ]); + + let currentText = ""; + let currentMutationReceipt: AIMutationReceipt | null = null; + let sawStructuredFinalFrame = false; + let streamedSelectionSuggestionIds: string[] = []; + let lastStreamedSelectionPreviewText = ""; + const updatePreview = (text: string, phase: "preview" | "final") => { + currentText = text; + const nextStatus = + phase === "preview" && text.length > 0 + ? "writing" + : controller._state.status; + if (phase === "preview" && text.length > 0) { + controller._setState({ status: "writing" }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "status", + status: "writing", + }), + ); + } + controller._resolveActiveGeneration({ + text, + status: "streaming", + operation, + }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "operation", + operation, + phase, + text, + }), + ); + void nextStatus; + }; + + try { + const stream = controller._model!.stream({ + messages: [{ role: "user", content: executionPrompt }], + tools: [], + signal: abortController.signal, + requestMode: resolveGenerationRequestMode({ + ...context, + targetType: target.type, + operation, + }), + operation, + sessionId: context?.sessionId, + turnId: sessionTurnId, + generationId: seedGeneration.id, + }); + + for await (const event of stream) { + if (abortController.signal.aborted) { + break; + } + + if (event.type === "error") { + throw event.error; + } + + if (event.type === "conflict") { + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "operation", + operation, + phase: "conflict", + reason: event.reason, + }), + ); + throw new Error(event.reason); + } + + if (event.type === "text-delta") { + if ( + operation.kind === "document-transform" || + streamsMarkdownSelectionPreview + ) { + currentText += event.delta; + if ( + streamsMarkdownSelectionPreview && + operation.target.kind === "scoped-range" + ) { + updatePreview(currentText, "preview"); + const previewRefresh = + controller._refreshStreamingMarkdownBlockPreview( + operation.target.blockIds?.[0] ?? + operation.target.anchor.blockId, + currentText, + mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedSelectionSuggestionIds, + lastStreamedSelectionPreviewText, + true, + operation.target.blockIds, + ); + streamedSelectionSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedSelectionPreviewText = + previewRefresh.normalizedText; + } + continue; + } + throw new Error( + "Local AI operations must stream typed operation payloads, not raw text deltas.", + ); + } + + if ( + event.type === "replace-preview" || + event.type === "insert-preview" + ) { + updatePreview(event.text, "preview"); + if ( + streamsMarkdownSelectionPreview && + operation.target.kind === "scoped-range" + ) { + const previewRefresh = + controller._refreshStreamingMarkdownBlockPreview( + operation.target.blockIds?.[0] ?? + operation.target.anchor.blockId, + event.text, + mutationMode, + context?.sessionId, + baselineSuggestionIds, + streamedSelectionSuggestionIds, + lastStreamedSelectionPreviewText, + true, + operation.target.blockIds, + ); + streamedSelectionSuggestionIds = + previewRefresh.suggestionIds; + lastStreamedSelectionPreviewText = + previewRefresh.normalizedText; + } + continue; + } + + if ( + event.type === "replace-final" || + event.type === "insert-final" + ) { + sawStructuredFinalFrame = true; + updatePreview(event.text, "final"); + if ( + streamsMarkdownSelectionPreview && + operation.target.kind === "scoped-range" + ) { + controller._rejectPreviewSuggestions( + streamedSelectionSuggestionIds, + ); + streamedSelectionSuggestionIds = []; + lastStreamedSelectionPreviewText = ""; + } + currentMutationReceipt = + controller._commitRequestedOperationResult( + operation, + event.text, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); + continue; + } + + if (event.type === "done") { + break; + } + } + + if ( + !sawStructuredFinalFrame && + currentText.length > 0 && + operation.kind !== "document-transform" && + !streamsMarkdownSelectionPreview + ) { + throw new Error( + "Local AI operations must return a validated final payload before they can be applied.", + ); + } + if ( + !sawStructuredFinalFrame && + currentText.length > 0 && + operation.kind === "document-transform" + ) { + currentMutationReceipt = controller._commitRequestedOperationResult( + operation, + currentText, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); + } else if ( + !sawStructuredFinalFrame && + currentText.length > 0 && + streamsMarkdownSelectionPreview + ) { + controller._rejectPreviewSuggestions(streamedSelectionSuggestionIds); + streamedSelectionSuggestionIds = []; + lastStreamedSelectionPreviewText = ""; + currentMutationReceipt = controller._commitRequestedOperationResult( + operation, + currentText, + context?.sessionId, + { + contentFormat, + applyStrategy, + }, + ); + } + return finalizeLocalOperationExecution(controller, { + context, + sessionTurnId, + operation, + currentText, + currentMutationReceipt, + seedGeneration, + abortController, + baselineSuggestionIds, + }); + controller._setState({ + status: "idle", + activeGeneration: { + ...seedGeneration, + text: currentText, + status: abortController.signal.aborted + ? "cancelled" + : "error", + }, + }); + if (context?.sessionId) { + if (sessionTurnId) { + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: abortController.signal.aborted + ? "cancelled" + : "error", + }); + } + controller._updateSession(context.sessionId, { + status: abortController.signal.aborted + ? "cancelled" + : "error", + }); + } + throw error; + } finally { + if (controller._abortController === abortController) { + controller._abortController = null; + } + } +} diff --git a/packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts b/packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts new file mode 100644 index 0000000..0a524f7 --- /dev/null +++ b/packages/extensions/ai/src/extensionParts/localOperationExecutionFinalize.ts @@ -0,0 +1,75 @@ +// @ts-nocheck +import * as deps from "./controllerDeps"; +const { buildMutationReceipt, createAIStreamEvent } = deps; + +export function finalizeLocalOperationExecution(controller: any, state: any): any { + const { context, sessionTurnId, operation, currentText, currentMutationReceipt, seedGeneration, abortController, baselineSuggestionIds } = state; + const suggestionIds = controller.getSuggestions() + .map((item) => item.id) + .filter((id) => !baselineSuggestionIds.has(id)); + const mutationReceipt = + currentMutationReceipt ?? + buildMutationReceipt({ + status: currentText.length > 0 ? "noop" : "noop", + adapterId: "flow-markdown", + blockClass: "flow", + transportKind: "flow-text", + }); + const finalStatus = abortController.signal.aborted + ? "cancelled" + : "complete"; + controller._setState({ + status: "idle", + activeGeneration: { + ...seedGeneration, + text: currentText, + status: finalStatus, + suggestionIds, + mutationReceipt, + }, + }); + controller._appendStreamEvent( + createAIStreamEvent(seedGeneration, { + type: "generation-finish", + status: finalStatus, + text: currentText, + }), + ); + if (context?.sessionId) { + if (sessionTurnId) { + const localReceiptEvidence = mutationReceipt?.evidence; + const localGeneratedBlockIds = localReceiptEvidence + ? [ + ...new Set([ + ...localReceiptEvidence.affectedBlockIds, + ...localReceiptEvidence.createdBlockIds, + ]), + ] + : operation.kind === "rewrite-selection" && + operation.target.kind === "scoped-range" + ? [...operation.target.blockIds] + : []; + controller._updateSessionTurn(context.sessionId, sessionTurnId, { + status: + finalStatus === "cancelled" + ? "cancelled" + : "complete", + suggestionIds, + generatedBlockIds: localGeneratedBlockIds, + }); + } + controller._updateSession(context.sessionId, { + status: + finalStatus === "cancelled" ? "cancelled" : "complete", + pendingSuggestionIds: suggestionIds, + pendingReviewItemIds: [], + }); + } + return { + ...seedGeneration, + text: currentText, + status: finalStatus, + suggestionIds, + mutationReceipt, + }; +} diff --git a/packages/extensions/ai/src/index.ts b/packages/extensions/ai/src/index.ts index 581e03b..e13e13c 100644 --- a/packages/extensions/ai/src/index.ts +++ b/packages/extensions/ai/src/index.ts @@ -48,23 +48,15 @@ export { applyMarkdownFastApply, parseMarkdownFastApplyContract, } from "./runtime/markdownFastApply"; -export { - parseMarkdownPatchPlanContract, -} from "./runtime/markdownPatchPlan"; -export { - DOCUMENT_MUTATION_PLAN_KINDS, -} from "./runtime/planTypes"; +export { parseMarkdownPatchPlanContract } from "./runtime/markdownPatchPlan"; +export { DOCUMENT_MUTATION_PLAN_KINDS } from "./runtime/planTypes"; export { PLAN_VALIDATION_SEVERITIES, isDocumentMutationPlan, validateDocumentMutationPlanShape, } from "./runtime/planValidation"; -export { - buildStructuralReviewItems, -} from "./runtime/reviewArtifacts"; -export { - buildDocumentMutationPlanExecution, -} from "./runtime/planExecutor"; +export { buildStructuralReviewItems } from "./runtime/reviewArtifacts"; +export { buildDocumentMutationPlanExecution } from "./runtime/planExecutor"; export { buildPlannerPrompt, parseStructuredPlanResult, @@ -108,6 +100,25 @@ export { SUGGESTION_RESOLUTION_ORIGIN, shouldBypassSuggestMode, } from "./suggestions/suggestMode"; +export { applySuggestedAIOperations } from "./suggestions/applySuggestedAIOperations"; +export { + compileRangeReplacementSuggestionOps, + compileReplacementSuggestionOps, +} from "./suggestions/textDiffOperations"; +export { + AI_REVIEW_ROLE_ATTRIBUTE, + AI_REVIEW_STATE_ATTRIBUTE, + AI_REVIEW_PREVIEW_NEW_ATTRIBUTE, + AI_REVIEW_PREVIEW_VIRTUAL_ATTRIBUTE, + FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE, + buildAIReviewPresentationDecorations, + buildStreamingReviewPreviewDecorations, + resolveAIReviewPresentationState, +} from "./review/reviewPresentation"; +export type { + AIReviewPresentationRole, + AIReviewPresentationState, +} from "./review/reviewPresentation"; export { EphemeralSuggestionManager } from "./suggestions/ephemeral"; export type { @@ -121,6 +132,9 @@ export type { AIContextualPromptState, AISession, AISessionAnchor, + AIStreamingReviewPreview, + AIStreamingReviewPreviewInput, + AIStreamingReviewPreviewTarget, AISessionMetrics, AISessionFastApplyMetrics, AISessionPrompt, @@ -128,6 +142,7 @@ export type { AISessionTarget, AISurface, AIAwarenessState, + AIExternalInlineTurnResult, AgenticStep, GenerationState, EphemeralSuggestion, @@ -149,6 +164,7 @@ export type { AIPromptTarget, AISessionResolution, AIContentFormatOptions, + AISuggestionPresentation, GenerationPlanState, GenerationTargetKind, StructuredGenerationDebugState, @@ -162,6 +178,14 @@ export type { AIMutationReceiptEvidence, AIMutationReceiptStatus, } from "./types"; +export type { + ApplySuggestedAIOperationsOptions, + ApplySuggestedAIOperationsResult, +} from "./suggestions/applySuggestedAIOperations"; +export type { + CompileReplacementSuggestionOpsInput, + ReplacementTextDiffOperation, +} from "./suggestions/textDiffOperations"; export type { DocumentMutationPlan, DocumentMutationPlanKind, diff --git a/packages/extensions/ai/src/review/contextDecorations.ts b/packages/extensions/ai/src/review/contextDecorations.ts new file mode 100644 index 0000000..a569e23 --- /dev/null +++ b/packages/extensions/ai/src/review/contextDecorations.ts @@ -0,0 +1,154 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { AIExtensionConfig, AISession } from "../types"; +import { + AI_REVIEW_ROLE_ATTRIBUTE, + AI_REVIEW_STATE_ATTRIBUTE, + type AIReviewPresentationState, +} from "./reviewPresentationState"; +import { AI_REVIEW_CONTEXT_STYLE } from "./reviewPresentationStyles"; +import { subtractRanges } from "./rangeHelpers"; +import type { SuggestionInlineRange } from "./suggestionDecorations"; + +type SuggestionPresentation = NonNullable< + AIExtensionConfig["suggestionPresentation"] +>; + +export function shouldShowSelectionContext({ + hasActiveStreamingReviewPreview, + hasSuggestions, + suggestionPresentation, +}: { + hasActiveStreamingReviewPreview: boolean; + hasSuggestions: boolean; + suggestionPresentation: SuggestionPresentation; +}): boolean { + if ( + suggestionPresentation === "final-text" && + (hasActiveStreamingReviewPreview || hasSuggestions) + ) { + return false; + } + + return true; +} + +export function buildContextDecorations({ + activeSession, + editor, + reviewState, + suggestionRangesByBlock, +}: { + activeSession: AISession | null; + editor: Editor; + reviewState: AIReviewPresentationState; + suggestionRangesByBlock: Map; +}): InlineDecoration[] { + if ( + !activeSession || + activeSession.surface !== "inline-edit" || + !activeSession.contextualPrompt?.composer.isOpen || + reviewState === "resolved" + ) { + return []; + } + + const selectionSnapshot = resolveContextSelection(activeSession); + if (!selectionSnapshot) { + return []; + } + + const decorations: InlineDecoration[] = []; + const blockRange = + selectionSnapshot.blockRange.length > 0 + ? selectionSnapshot.blockRange + : [selectionSnapshot.anchor.blockId]; + const firstBlockId = blockRange[0] ?? null; + const lastBlockId = blockRange[blockRange.length - 1] ?? firstBlockId; + if (!firstBlockId || !lastBlockId) { + return decorations; + } + + for (const blockId of blockRange) { + const block = editor.getBlock(blockId); + if (!block) { + continue; + } + + const isSingleBlock = firstBlockId === lastBlockId; + const blockTextLength = block.textContent({ resolved: true }).length; + const from = isSingleBlock + ? Math.min( + selectionSnapshot.anchor.offset, + selectionSnapshot.focus.offset, + ) + : blockId === firstBlockId + ? resolveBoundaryOffset(selectionSnapshot, firstBlockId) + : 0; + const to = isSingleBlock + ? Math.max( + selectionSnapshot.anchor.offset, + selectionSnapshot.focus.offset, + ) + : blockId === lastBlockId + ? resolveBoundaryOffset(selectionSnapshot, lastBlockId) + : blockTextLength; + if (to <= from) { + continue; + } + + const excludedRanges = + reviewState === "user-reviewing" + ? (suggestionRangesByBlock.get(blockId) ?? []) + : []; + for (const range of subtractRanges({ from, to }, excludedRanges)) { + decorations.push({ + type: "inline", + blockId, + from: range.from, + to: range.to, + key: `ai-review-context:${blockId}:${range.from}:${range.to}`, + attributes: { + class: "pen-ai-review-context pen-ai-affected-range", + "data-ai-affected-range": "", + "data-ai-affected-range-session": "", + [AI_REVIEW_ROLE_ATTRIBUTE]: "context", + [AI_REVIEW_STATE_ATTRIBUTE]: reviewState, + style: AI_REVIEW_CONTEXT_STYLE, + }, + }); + } + } + + return decorations; +} + +function resolveContextSelection(session: AISession) { + const activeTurn = + session.activeTurnId != null + ? (session.turns.find((turn) => turn.id === session.activeTurnId) ?? + null) + : (session.turns[session.turns.length - 1] ?? null); + + return ( + activeTurn?.selection ?? + session.contextualPrompt?.anchor.selectionSnapshot ?? + (session.target.kind === "selection" + ? { + anchor: { ...session.target.selection.anchor }, + focus: { ...session.target.selection.focus }, + blockRange: [...session.target.selection.blockRange], + isMultiBlock: session.target.selection.isMultiBlock, + } + : null) + ); +} + +function resolveBoundaryOffset( + selectionSnapshot: NonNullable>, + blockId: string, +): number { + if (selectionSnapshot.anchor.blockId === blockId) { + return selectionSnapshot.anchor.offset; + } + return selectionSnapshot.focus.offset; +} diff --git a/packages/extensions/ai/src/review/rangeHelpers.ts b/packages/extensions/ai/src/review/rangeHelpers.ts new file mode 100644 index 0000000..db8c09e --- /dev/null +++ b/packages/extensions/ai/src/review/rangeHelpers.ts @@ -0,0 +1,31 @@ +export interface InlineRange { + from: number; + to: number; +} + +export function subtractRanges( + range: InlineRange, + excludedRanges: InlineRange[], +): InlineRange[] { + let ranges = [range]; + for (const excludedRange of excludedRanges) { + ranges = ranges.flatMap((candidate) => + subtractRange(candidate, excludedRange), + ); + } + return ranges; +} + +export function subtractRange( + range: InlineRange, + excludedRange: InlineRange, +): InlineRange[] { + if (excludedRange.to <= range.from || excludedRange.from >= range.to) { + return [range]; + } + + return [ + { from: range.from, to: Math.max(range.from, excludedRange.from) }, + { from: Math.min(range.to, excludedRange.to), to: range.to }, + ].filter((candidate) => candidate.to > candidate.from); +} diff --git a/packages/extensions/ai/src/review/reviewPresentation.ts b/packages/extensions/ai/src/review/reviewPresentation.ts new file mode 100644 index 0000000..08de7e5 --- /dev/null +++ b/packages/extensions/ai/src/review/reviewPresentation.ts @@ -0,0 +1,95 @@ +import type { Decoration, Editor } from "@pen/types"; +import type { + AIExtensionConfig, + AISession, + AIStreamingReviewPreview, + GenerationState, +} from "../types"; +import { + buildContextDecorations, + shouldShowSelectionContext, +} from "./contextDecorations"; +import { + resolveAIReviewPresentationState, + AI_REVIEW_PREVIEW_NEW_ATTRIBUTE, + AI_REVIEW_PREVIEW_VIRTUAL_ATTRIBUTE, + AI_REVIEW_ROLE_ATTRIBUTE, + AI_REVIEW_STATE_ATTRIBUTE, + FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE, +} from "./reviewPresentationState"; +import { collectSuggestionDecorations } from "./suggestionDecorations"; +import { buildStreamingReviewPreviewDecorations } from "./streamingPreviewDecorations"; + +export { + AI_REVIEW_PREVIEW_NEW_ATTRIBUTE, + AI_REVIEW_PREVIEW_VIRTUAL_ATTRIBUTE, + AI_REVIEW_ROLE_ATTRIBUTE, + AI_REVIEW_STATE_ATTRIBUTE, + FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE, + resolveAIReviewPresentationState, +} from "./reviewPresentationState"; +export type { + AIReviewPresentationRole, + AIReviewPresentationState, +} from "./reviewPresentationState"; +export { buildStreamingReviewPreviewDecorations } from "./streamingPreviewDecorations"; + +export function buildAIReviewPresentationDecorations({ + activeGeneration, + activeSessionId, + editor, + sessions, + suggestionPresentation, + streamingReviewPreview, +}: { + activeGeneration?: GenerationState | null; + activeSessionId: string | null | undefined; + editor: Editor; + sessions: readonly AISession[]; + suggestionPresentation: NonNullable< + AIExtensionConfig["suggestionPresentation"] + >; + streamingReviewPreview?: AIStreamingReviewPreview | null; +}): Decoration[] { + const activeSession = + sessions.find((session) => session.id === activeSessionId) ?? null; + const { + decorations: suggestionDecorations, + suggestionRangesByBlock, + hasSuggestions, + } = collectSuggestionDecorations(editor, suggestionPresentation); + + const reviewState = resolveAIReviewPresentationState({ + activeGeneration, + activeSession, + hasSuggestions, + }); + const hasActiveStreamingReviewPreview = + activeSession != null && + streamingReviewPreview?.sessionId === activeSession.id; + const contextDecorations = shouldShowSelectionContext({ + hasActiveStreamingReviewPreview, + hasSuggestions, + suggestionPresentation, + }) + ? buildContextDecorations({ + activeSession, + editor, + reviewState, + suggestionRangesByBlock, + }) + : []; + const previewDecorations = hasActiveStreamingReviewPreview + ? buildStreamingReviewPreviewDecorations({ + editor, + preview: streamingReviewPreview, + suggestionPresentation, + }) + : []; + + return [ + ...suggestionDecorations, + ...contextDecorations, + ...previewDecorations, + ]; +} diff --git a/packages/extensions/ai/src/review/reviewPresentationState.ts b/packages/extensions/ai/src/review/reviewPresentationState.ts new file mode 100644 index 0000000..9b94135 --- /dev/null +++ b/packages/extensions/ai/src/review/reviewPresentationState.ts @@ -0,0 +1,57 @@ +import type { AISession, GenerationState } from "../types"; + +export type AIReviewPresentationState = + | "user-input" + | "thinking" + | "ai-writing" + | "user-reviewing" + | "resolved"; + +export type AIReviewPresentationRole = + | "context" + | "insert" + | "delete-hidden" + | "block-insert" + | "block-delete" + | "block-change" + | "active-change" + | "generation-zone"; + +export const AI_REVIEW_ROLE_ATTRIBUTE = "data-pen-ai-review-role"; +export const AI_REVIEW_STATE_ATTRIBUTE = "data-pen-ai-review-state"; +export const FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE = + "data-pen-final-text-review-hidden"; +export const AI_REVIEW_PREVIEW_VIRTUAL_ATTRIBUTE = + "data-pen-ai-review-preview-virtual"; +export const AI_REVIEW_PREVIEW_NEW_ATTRIBUTE = "data-pen-ai-review-preview-new"; + +export function resolveAIReviewPresentationState({ + activeGeneration, + activeSession, + hasSuggestions, +}: { + activeGeneration?: GenerationState | null; + activeSession: AISession | null; + hasSuggestions: boolean; +}): AIReviewPresentationState { + if ( + !activeSession || + activeSession.surface !== "inline-edit" || + !activeSession.contextualPrompt?.composer.isOpen + ) { + return "resolved"; + } + + if (hasSuggestions) { + return "user-reviewing"; + } + + if ( + activeGeneration?.sessionId === activeSession.id && + activeGeneration.status === "streaming" + ) { + return "ai-writing"; + } + + return "user-input"; +} diff --git a/packages/extensions/ai/src/review/reviewPresentationStyles.ts b/packages/extensions/ai/src/review/reviewPresentationStyles.ts new file mode 100644 index 0000000..53919ab --- /dev/null +++ b/packages/extensions/ai/src/review/reviewPresentationStyles.ts @@ -0,0 +1,32 @@ +export const AI_STREAMING_PREVIEW_CHAR_STAGGER_MS = 4; + +export const AI_REVIEW_INLINE_STYLE = [ + "padding-block: var(--pen-ai-review-inline-padding-block, 0.2em)", + "margin-block: var(--pen-ai-review-inline-margin-block, -0.2em)", + "border-radius: var(--pen-ai-review-border-radius, 3px)", + "box-decoration-break: clone", + "-webkit-box-decoration-break: clone", +].join("; "); + +export const AI_REVIEW_INSERT_STYLE = [ + "color: var(--pen-ai-review-insert-color, #6d28d9)", + "background: var(--pen-ai-review-insert-background, color-mix(in srgb, #7c3aed 12%, transparent))", + AI_REVIEW_INLINE_STYLE, +].join("; "); + +export const AI_REVIEW_CONTEXT_STYLE = [ + "color: inherit", + "background: var(--pen-ai-review-context-background, color-mix(in srgb, #2563eb 14%, transparent))", + "box-shadow: var(--pen-ai-review-context-box-shadow, none)", + AI_REVIEW_INLINE_STYLE, +].join("; "); + +export function buildStreamingPreviewNewStyle(animationDelayMs = 0): string { + return [ + AI_REVIEW_INSERT_STYLE, + "animation: var(--pen-ai-review-preview-new-animation, none)", + animationDelayMs > 0 ? `animation-delay: ${animationDelayMs}ms` : "", + ] + .filter(Boolean) + .join("; "); +} diff --git a/packages/extensions/ai/src/review/streamingPreviewDecorations.ts b/packages/extensions/ai/src/review/streamingPreviewDecorations.ts new file mode 100644 index 0000000..1989fb8 --- /dev/null +++ b/packages/extensions/ai/src/review/streamingPreviewDecorations.ts @@ -0,0 +1,371 @@ +import type { + Decoration, + Editor, + InlineDecoration, +} from "@pen/types"; +import type { AIExtensionConfig, AIStreamingReviewPreview } from "../types"; +import { mapStreamingBlockRangeTextOffset } from "../suggestions/replacementPlan/blockRangeTextOffset"; +import { + buildStreamingPreviewPlan, + normalizeStreamingBlockRange, + type BlockRangeStreamingPreviewPlan, + type StreamingPreviewPlanResult, +} from "../suggestions/replacementPlan/streamingPreviewPlan"; +import { + createStreamingDeleteBlockDecoration, + createStreamingDeleteDecoration, +} from "./streamingPreviewDeleteDecorations"; +import { + appendVirtualPreviewTextDecorations, + resolveStreamingPreviewAnchor, + resolveStreamingPreviewInsertedTextStart, +} from "./streamingPreviewVirtualDecorations"; + +type SuggestionPresentation = NonNullable< + AIExtensionConfig["suggestionPresentation"] +>; + +export function buildStreamingReviewPreviewDecorations({ + editor, + preview, + suggestionPresentation, +}: { + editor: Editor; + preview: AIStreamingReviewPreview; + suggestionPresentation: SuggestionPresentation; +}): Decoration[] { + const text = preview.text; + if (text.length === 0) { + return []; + } + const anchor = resolveStreamingPreviewAnchor(preview); + if (!anchor) { + return []; + } + + const decorations: Decoration[] = []; + const replacementPlan = buildStreamingPreviewPlan(editor, preview); + if (replacementPlan) { + appendStreamingReplacementPreviewPlanDecorations(decorations, { + editor, + plan: replacementPlan, + preview, + suggestionPresentation, + }); + return decorations; + } + + appendStreamingPreviewDeletionDecorations(decorations, { + editor, + suggestionPresentation, + target: preview.target, + }); + appendVirtualPreviewTextDecorations(decorations, { + blockId: anchor.blockId, + offset: anchor.offset, + preview, + text, + insertedTextStart: 0, + }); + + return decorations; +} + +function appendStreamingReplacementPreviewPlanDecorations( + decorations: Decoration[], + { + editor, + plan, + preview, + suggestionPresentation, + }: { + editor: Editor; + plan: StreamingPreviewPlanResult; + preview: AIStreamingReviewPreview; + suggestionPresentation: SuggestionPresentation; + }, +): void { + if (plan.kind === "text-range") { + if (plan.deleteTo > plan.deleteFrom) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: plan.blockId, + from: plan.deleteFrom, + suggestionPresentation, + to: plan.deleteTo, + }), + ); + } + appendVirtualPreviewTextDecorations(decorations, { + blockId: plan.blockId, + offset: plan.insertOffset, + preview, + text: plan.text, + insertedTextStart: resolveStreamingPreviewInsertedTextStart({ + decoratedText: plan.text, + preview, + planInsertedTextStart: plan.insertedTextStart, + }), + }); + return; + } + if (plan.kind === "aligned-block-range") { + for (const textPlan of plan.plans) { + appendStreamingReplacementPreviewPlanDecorations(decorations, { + editor, + plan: textPlan, + preview, + suggestionPresentation, + }); + } + return; + } + + appendBlockRangeStreamingReplacementPreviewDecorations(decorations, { + editor, + plan, + preview, + suggestionPresentation, + }); +} + +function appendBlockRangeStreamingReplacementPreviewDecorations( + decorations: Decoration[], + { + editor, + plan, + preview, + suggestionPresentation, + }: { + editor: Editor; + plan: BlockRangeStreamingPreviewPlan; + preview: AIStreamingReviewPreview; + suggestionPresentation: SuggestionPresentation; + }, +): void { + const insertPosition = mapStreamingBlockRangeTextOffset( + editor, + plan.normalizedRange, + plan.deleteFromChar, + ); + const deleteStartPosition = insertPosition; + const deleteEndPosition = mapStreamingBlockRangeTextOffset( + editor, + plan.normalizedRange, + plan.deleteToChar, + ); + + if (deleteStartPosition.blockId === deleteEndPosition.blockId) { + if (deleteEndPosition.offset > deleteStartPosition.offset) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: deleteStartPosition.blockId, + from: deleteStartPosition.offset, + suggestionPresentation, + to: deleteEndPosition.offset, + }), + ); + } + } else { + appendPartialBlockRangeDeletionDecorations(decorations, { + editor, + deleteEndPosition, + deleteStartPosition, + normalizedRange: plan.normalizedRange, + suggestionPresentation, + }); + } + + appendVirtualPreviewTextDecorations(decorations, { + blockId: insertPosition.blockId, + offset: insertPosition.offset, + preview, + text: plan.insertText, + insertedTextStart: resolveStreamingPreviewInsertedTextStart({ + decoratedText: plan.insertText, + preview, + planInsertedTextStart: plan.insertedTextStart, + }), + }); +} + +function appendPartialBlockRangeDeletionDecorations( + decorations: Decoration[], + { + editor, + deleteEndPosition, + deleteStartPosition, + normalizedRange, + suggestionPresentation, + }: { + editor: Editor; + deleteEndPosition: { blockId: string; offset: number }; + deleteStartPosition: { blockId: string; offset: number }; + normalizedRange: BlockRangeStreamingPreviewPlan["normalizedRange"]; + suggestionPresentation: SuggestionPresentation; + }, +): void { + const orderedBlockIds = [ + normalizedRange.start.blockId, + ...normalizedRange.middleBlockIds, + normalizedRange.end.blockId, + ].filter((blockId, index, blockIds) => blockIds.indexOf(blockId) === index); + const deleteStartIndex = orderedBlockIds.indexOf( + deleteStartPosition.blockId, + ); + const deleteEndIndex = orderedBlockIds.indexOf(deleteEndPosition.blockId); + if (deleteStartIndex < 0 || deleteEndIndex < 0) { + return; + } + + const fromIndex = Math.min(deleteStartIndex, deleteEndIndex); + const toIndex = Math.max(deleteStartIndex, deleteEndIndex); + + const startBlockTextLength = + editor.getBlock(deleteStartPosition.blockId)?.textContent().length ?? + deleteStartPosition.offset; + if (deleteStartPosition.offset < startBlockTextLength) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: deleteStartPosition.blockId, + from: deleteStartPosition.offset, + suggestionPresentation, + to: startBlockTextLength, + }), + ); + } + + for (let index = fromIndex + 1; index < toIndex; index += 1) { + const blockId = orderedBlockIds[index]; + if (!blockId) { + continue; + } + decorations.push(createStreamingDeleteBlockDecoration(blockId)); + } + + if ( + deleteEndPosition.blockId !== deleteStartPosition.blockId && + deleteEndPosition.offset > 0 + ) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: deleteEndPosition.blockId, + from: 0, + suggestionPresentation, + to: deleteEndPosition.offset, + }), + ); + } +} + +function appendStreamingPreviewDeletionDecorations( + decorations: Decoration[], + input: { + editor: Editor; + suggestionPresentation: SuggestionPresentation; + target: AIStreamingReviewPreview["target"]; + }, +): void { + switch (input.target.kind) { + case "text-range": { + const from = Math.min(input.target.from, input.target.to); + const to = Math.max(input.target.from, input.target.to); + if (to > from) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: input.target.blockId, + from, + suggestionPresentation: input.suggestionPresentation, + to, + }), + ); + } + return; + } + case "block-range": + appendStreamingBlockRangeDeletionDecorations(decorations, { + editor: input.editor, + suggestionPresentation: input.suggestionPresentation, + target: input.target, + }); + return; + case "insertion-point": + return; + default: { + const exhaustive: never = input.target; + return exhaustive; + } + } +} + +function appendStreamingBlockRangeDeletionDecorations( + decorations: Decoration[], + { + editor, + suggestionPresentation, + target, + }: { + editor: Editor; + suggestionPresentation: SuggestionPresentation; + target: Extract< + AIStreamingReviewPreview["target"], + { kind: "block-range" } + >; + }, +): void { + const normalizedRange = normalizeStreamingBlockRange(editor, target); + if (!normalizedRange) { + return; + } + + if (normalizedRange.start.blockId === normalizedRange.end.blockId) { + const from = Math.min( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + const to = Math.max( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + if (to > from) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: normalizedRange.start.blockId, + from, + suggestionPresentation, + to, + }), + ); + } + return; + } + + const startBlockTextLength = + editor.getBlock(normalizedRange.start.blockId)?.textContent().length ?? + normalizedRange.start.offset; + if (normalizedRange.start.offset < startBlockTextLength) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: normalizedRange.start.blockId, + from: normalizedRange.start.offset, + suggestionPresentation, + to: startBlockTextLength, + }), + ); + } + + if (normalizedRange.end.offset > 0) { + decorations.push( + createStreamingDeleteDecoration({ + blockId: normalizedRange.end.blockId, + from: 0, + suggestionPresentation, + to: normalizedRange.end.offset, + }), + ); + } + + for (const blockId of normalizedRange.middleBlockIds) { + decorations.push(createStreamingDeleteBlockDecoration(blockId)); + } +} diff --git a/packages/extensions/ai/src/review/streamingPreviewDeleteDecorations.ts b/packages/extensions/ai/src/review/streamingPreviewDeleteDecorations.ts new file mode 100644 index 0000000..62c01c1 --- /dev/null +++ b/packages/extensions/ai/src/review/streamingPreviewDeleteDecorations.ts @@ -0,0 +1,63 @@ +import type { BlockDecoration, InlineDecoration } from "@pen/types"; +import type { AIExtensionConfig } from "../types"; +import { + AI_REVIEW_ROLE_ATTRIBUTE, + FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE, +} from "./reviewPresentationState"; + +type SuggestionPresentation = NonNullable< + AIExtensionConfig["suggestionPresentation"] +>; +type DecorationAttributes = Record; + +export function createStreamingDeleteDecoration({ + blockId, + from, + suggestionPresentation, + to, +}: { + blockId: string; + from: number; + suggestionPresentation: SuggestionPresentation; + to: number; +}): InlineDecoration { + return { + type: "inline", + blockId, + from, + to, + attributes: buildStreamingDeleteAttributes(suggestionPresentation), + omitFromRender: suggestionPresentation === "final-text", + }; +} + +export function buildStreamingDeleteAttributes( + suggestionPresentation: SuggestionPresentation, +): DecorationAttributes { + if (suggestionPresentation === "final-text") { + return { + class: "pen-ai-review-preview-original", + [AI_REVIEW_ROLE_ATTRIBUTE]: "delete-hidden", + [FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE]: true, + }; + } + + return { + class: "pen-ai-review-preview-original pen-suggestion-delete pen-ai-review-delete", + [AI_REVIEW_ROLE_ATTRIBUTE]: "delete", + }; +} + +export function createStreamingDeleteBlockDecoration( + blockId: string, +): BlockDecoration { + return { + type: "block", + blockId, + attributes: { + class: "pen-block-suggestion pen-block-suggestion-delete-block", + "data-suggestion-action": "delete-block", + [AI_REVIEW_ROLE_ATTRIBUTE]: "block-delete", + }, + }; +} diff --git a/packages/extensions/ai/src/review/streamingPreviewVirtualDecorations.ts b/packages/extensions/ai/src/review/streamingPreviewVirtualDecorations.ts new file mode 100644 index 0000000..cb71380 --- /dev/null +++ b/packages/extensions/ai/src/review/streamingPreviewVirtualDecorations.ts @@ -0,0 +1,211 @@ +import type { Decoration, InlineDecoration } from "@pen/types"; +import type { AIStreamingReviewPreview } from "../types"; +import { + AI_REVIEW_PREVIEW_NEW_ATTRIBUTE, + AI_REVIEW_PREVIEW_VIRTUAL_ATTRIBUTE, + AI_REVIEW_ROLE_ATTRIBUTE, +} from "./reviewPresentationState"; +import { + AI_REVIEW_INSERT_STYLE, + AI_STREAMING_PREVIEW_CHAR_STAGGER_MS, +} from "./reviewPresentationStyles"; + +export function resolveStreamingPreviewInsertedTextStart({ + decoratedText, + preview, + planInsertedTextStart, +}: { + decoratedText: string; + preview: AIStreamingReviewPreview; + planInsertedTextStart: number; +}): number { + if (planInsertedTextStart <= 0) { + return 0; + } + + if (preview.text.length <= decoratedText.length + 1) { + return 0; + } + + if (planInsertedTextStart >= preview.text.length) { + return 0; + } + + if (preview.text.length >= decoratedText.length + planInsertedTextStart) { + return planInsertedTextStart; + } + + return 0; +} + +export function resolveStreamingPreviewStableTextLength({ + decoratedText, + insertedTextStart, + preview, +}: { + decoratedText: string; + insertedTextStart: number; + preview: AIStreamingReviewPreview; +}): number { + if (insertedTextStart === 0) { + return Math.max( + 0, + Math.min(preview.previousTextLength, decoratedText.length), + ); + } + + const previousDecoratedLength = Math.max( + 0, + preview.previousTextLength - insertedTextStart, + ); + return Math.max(0, Math.min(previousDecoratedLength, decoratedText.length)); +} + +export function resolveStreamingPreviewAnchor( + preview: AIStreamingReviewPreview, +): { blockId: string; offset: number } | null { + switch (preview.target.kind) { + case "text-range": + return { + blockId: preview.target.blockId, + offset: Math.min(preview.target.from, preview.target.to), + }; + case "insertion-point": + return { + blockId: preview.target.blockId, + offset: preview.target.offset, + }; + case "block-range": + return { + blockId: preview.target.start.blockId, + offset: preview.target.start.offset, + }; + default: { + const exhaustive: never = preview.target; + return exhaustive; + } + } +} + +export function appendVirtualPreviewTextDecorations( + decorations: Decoration[], + { + blockId, + insertedTextStart, + offset, + preview, + text, + }: { + blockId: string; + insertedTextStart: number; + offset: number; + preview: AIStreamingReviewPreview; + text: string; + }, +): void { + if (text.length === 0) { + return; + } + + const stableTextLength = resolveStreamingPreviewStableTextLength({ + decoratedText: text, + insertedTextStart, + preview, + }); + const stableText = text.slice(0, stableTextLength); + const newText = text.slice(stableTextLength); + if (stableText.length > 0) { + decorations.push( + createVirtualPreviewDecoration({ + blockId, + offset, + preview, + text: stableText, + isNew: false, + keySuffix: `stable:${blockId}:${offset}:${insertedTextStart}`, + }), + ); + } + if (newText.length > 0) { + Array.from(newText).forEach((character, index) => { + decorations.push( + createVirtualPreviewDecoration({ + blockId, + offset, + preview, + text: character, + isNew: true, + keySuffix: `new:${blockId}:${offset}:${insertedTextStart + stableTextLength + index}`, + animationDelayMs: + index * AI_STREAMING_PREVIEW_CHAR_STAGGER_MS, + }), + ); + }); + } +} + +function createVirtualPreviewDecoration({ + animationDelayMs, + blockId, + keySuffix, + offset, + preview, + text, + isNew, +}: { + animationDelayMs?: number; + blockId: string; + keySuffix?: string; + offset: number; + preview: AIStreamingReviewPreview; + text: string; + isNew: boolean; +}): InlineDecoration { + return { + type: "inline", + blockId, + from: offset, + to: offset, + virtualText: text, + virtualPlacement: "after", + key: [ + "ai-streaming-review-preview", + preview.sessionId, + preview.turnId ?? "turn", + preview.revision, + keySuffix ?? (isNew ? "new" : "stable"), + ].join(":"), + attributes: { + class: [ + "pen-suggestion-insert", + "pen-suggestion-final-text-change", + "pen-ai-review-insert", + "pen-ai-review-preview", + isNew ? "pen-ai-review-preview-new" : "", + ] + .filter(Boolean) + .join(" "), + [AI_REVIEW_ROLE_ATTRIBUTE]: "insert", + [AI_REVIEW_PREVIEW_VIRTUAL_ATTRIBUTE]: true, + [AI_REVIEW_PREVIEW_NEW_ATTRIBUTE]: isNew, + "data-pen-ai-preview-streaming": true, + "data-pen-ai-preview-revision": preview.revision, + "data-pen-ai-preview-updated-at": preview.updatedAt, + "data-pen-final-text-review-change": true, + contenteditable: "false", + style: isNew + ? buildStreamingPreviewNewStyle(animationDelayMs) + : AI_REVIEW_INSERT_STYLE, + }, + } as InlineDecoration; +} + +function buildStreamingPreviewNewStyle(animationDelayMs = 0): string { + return [ + AI_REVIEW_INSERT_STYLE, + "animation: var(--pen-ai-review-preview-new-animation, none)", + animationDelayMs > 0 ? `animation-delay: ${animationDelayMs}ms` : "", + ] + .filter(Boolean) + .join("; "); +} diff --git a/packages/extensions/ai/src/review/suggestionDecorations.ts b/packages/extensions/ai/src/review/suggestionDecorations.ts new file mode 100644 index 0000000..1cea5a7 --- /dev/null +++ b/packages/extensions/ai/src/review/suggestionDecorations.ts @@ -0,0 +1,194 @@ +import type { + BlockDecoration, + Decoration, + Editor, + InlineDecoration, +} from "@pen/types"; +import { readBlockSuggestionMeta } from "../suggestions/persistent"; +import type { AIExtensionConfig } from "../types"; +import { + AI_REVIEW_ROLE_ATTRIBUTE, + FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE, + type AIReviewPresentationRole, +} from "./reviewPresentationState"; +import { AI_REVIEW_INSERT_STYLE } from "./reviewPresentationStyles"; + +interface InlineRange { + from: number; + to: number; +} + +export interface SuggestionInlineRange extends InlineRange { + action: "insert" | "delete"; + attributes: DecorationAttributes; +} + +interface YTextLike { + toDelta(): Array<{ + insert: string | object; + attributes?: Record; + }>; +} + +type SuggestionPresentation = NonNullable< + AIExtensionConfig["suggestionPresentation"] +>; +type DecorationAttributes = Record; + +export function collectSuggestionDecorations( + editor: Editor, + suggestionPresentation: SuggestionPresentation, +): { + decorations: Decoration[]; + suggestionRangesByBlock: Map; + hasSuggestions: boolean; +} { + const suggestionDecorations: Decoration[] = []; + const suggestionRangesByBlock = new Map(); + let hasSuggestions = false; + + for (const block of editor.documentState.allBlocks()) { + const blockSuggestion = readBlockSuggestionMeta(block); + if (blockSuggestion) { + hasSuggestions = true; + const role = resolveBlockSuggestionRole(blockSuggestion.action); + const blockDecoration: BlockDecoration = { + type: "block", + blockId: block.id, + attributes: { + class: `pen-block-suggestion pen-block-suggestion-${blockSuggestion.action}`, + "data-suggestion-id": blockSuggestion.id, + "data-suggestion-action": blockSuggestion.action, + "data-suggestion-author-type": blockSuggestion.authorType, + [AI_REVIEW_ROLE_ATTRIBUTE]: role, + }, + }; + suggestionDecorations.push(blockDecoration); + } + + const ranges = readSuggestionInlineRanges( + editor, + block.id, + suggestionPresentation, + ); + if (ranges.length > 0) { + hasSuggestions = true; + suggestionRangesByBlock.set(block.id, ranges); + suggestionDecorations.push( + ...ranges.map((range) => + createSuggestionInlineDecoration(block.id, range), + ), + ); + } + } + + return { + decorations: suggestionDecorations, + suggestionRangesByBlock, + hasSuggestions, + }; +} + +export function readSuggestionInlineRanges( + editor: Editor, + blockId: string, + suggestionPresentation: SuggestionPresentation, +): SuggestionInlineRange[] { + const ytext = editor.internals.getBlockText(blockId) as YTextLike | null; + if (!ytext || typeof ytext.toDelta !== "function") { + return []; + } + + const ranges: SuggestionInlineRange[] = []; + let offset = 0; + for (const delta of ytext.toDelta()) { + const length = + typeof delta.insert === "string" ? delta.insert.length : 1; + const suggestion = delta.attributes?.suggestion as + | Record + | undefined; + if (suggestion && typeof suggestion.id === "string") { + const action = suggestion.action === "delete" ? "delete" : "insert"; + ranges.push({ + action, + from: offset, + to: offset + length, + attributes: buildSuggestionAttributes( + action, + suggestion, + suggestionPresentation, + ), + }); + } + offset += length; + } + + return ranges; +} + +export function buildSuggestionAttributes( + action: "insert" | "delete", + suggestion: Record, + suggestionPresentation: SuggestionPresentation, +): DecorationAttributes { + if (suggestionPresentation === "final-text") { + return { + class: + action === "delete" + ? "pen-suggestion-delete pen-ai-review-delete" + : "pen-suggestion-insert pen-suggestion-final-text-change pen-ai-review-insert", + "data-suggestion-id": String(suggestion.id), + "data-suggestion-action": action, + "data-suggestion-author": String(suggestion.author ?? ""), + "data-suggestion-author-type": String( + suggestion.authorType ?? "user", + ), + [AI_REVIEW_ROLE_ATTRIBUTE]: + action === "delete" ? "delete-hidden" : "insert", + ...(action === "delete" + ? { [FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE]: true } + : { + "data-pen-final-text-review-change": true, + style: AI_REVIEW_INSERT_STYLE, + }), + }; + } + + return { + class: `pen-suggestion-${action} pen-ai-review-${action}`, + "data-suggestion-id": String(suggestion.id), + "data-suggestion-action": action, + "data-suggestion-author": String(suggestion.author ?? ""), + "data-suggestion-author-type": String(suggestion.authorType ?? "user"), + [AI_REVIEW_ROLE_ATTRIBUTE]: action, + }; +} + +export function createSuggestionInlineDecoration( + blockId: string, + range: SuggestionInlineRange, +): InlineDecoration { + return { + type: "inline", + blockId, + from: range.from, + to: range.to, + attributes: range.attributes, + omitFromRender: + range.action === "delete" && + range.attributes[FINAL_TEXT_REVIEW_HIDDEN_ATTRIBUTE] === true, + }; +} + +export function resolveBlockSuggestionRole( + action: string, +): AIReviewPresentationRole { + switch (action) { + case "insert-block": + return "block-insert"; + case "delete-block": + return "block-delete"; + default: + return "block-change"; + } +} diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts new file mode 100644 index 0000000..4fb1667 --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part1.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { createPlanExecutorEditor } from "./planExecutor.testUtils"; + +describe("document mutation plan executor", () => { + it("builds replace-text ops for text edit plans", () => { + const editor = createPlanExecutorEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "text_edit", + target: { + blockId, + range: { + startOffset: 6, + endOffset: 11, + }, + }, + operation: "replace", + text: "planet", + }); + + expect(execution.reviewSafe).toBe(true); + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "replace-text", + blockId, + offset: 6, + length: 5, + text: "planet", + }, + ]); + }); + + it("builds native ops for flow patch plans", () => { + const editor = createPlanExecutorEditor(); + const firstBlockId = editor.firstBlock()!.id; + editor.apply( + [{ + type: "replace-text", + blockId: firstBlockId, + offset: 0, + length: 0, + text: "Alpha", + }], + { origin: "system" }, + ); + editor.apply( + [{ + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstBlockId }, + }, { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Bravo", + }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am updating the current paragraph and inserting a heading after it.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstBlockId}`, + edits: [ + { + operation: "replace_text", + locator: { + blockId: firstBlockId, + expectedBlockType: "paragraph", + }, + text: "Alpha updated", + }, + { + operation: "insert_after", + locator: { + blockId: "block-2", + }, + markdown: "## Next step", + }, + ], + }); + + expect(execution.reviewSafe).toBe(true); + expect(execution.issues).toEqual([]); + expect(execution.ops[0]).toEqual({ + type: "replace-text", + blockId: firstBlockId, + offset: 0, + length: 5, + text: "Alpha updated", + }); + expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); + expect(execution.ops.some((op) => op.type === "insert-text")).toBe(true); + }); + + it("optimizes single-block markdown replacements into native ops", () => { + const editor = createPlanExecutorEditor(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Old title" }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am turning the paragraph into a heading with new copy.", + scope: "single-block", + targetSpanId: `span:${blockId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [blockId], + }, + markdown: "## New title", + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "convert-block", + blockId, + newType: "heading", + newProps: { level: 2 }, + }, + { + type: "replace-text", + blockId, + offset: 0, + length: "Old title".length, + text: "New title", + }, + ]); + }); + + it("optimizes adjacent multi-block markdown replacements into native ops", () => { + const editor = createPlanExecutorEditor(); + const headingId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "convert-block", blockId: headingId, newType: "heading", newProps: { level: 1 } }, + { type: "insert-text", blockId: headingId, offset: 0, text: "Old heading" }, + { + type: "insert-block", + blockId: "paragraph-2", + blockType: "paragraph", + props: {}, + position: { after: headingId }, + }, + { + type: "insert-text", + blockId: "paragraph-2", + offset: 0, + text: "Old body", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am rewriting the heading and paragraph together.", + scope: "adjacent-blocks", + targetSpanId: `span:${headingId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [headingId, "paragraph-2"], + }, + markdown: ["## New heading", "", "New body copy"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "update-block", + blockId: headingId, + props: { level: 2 }, + }, + { + type: "replace-text", + blockId: headingId, + offset: 0, + length: "Old heading".length, + text: "New heading", + }, + { + type: "replace-text", + blockId: "paragraph-2", + offset: 0, + length: "Old body".length, + text: "New body copy", + }, + ]); + }); + + it("optimizes adjacent list rewrites into native ops", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "convert-block", blockId: firstId, newType: "bulletListItem", newProps: { indent: 0 } }, + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "item-2", + blockType: "bulletListItem", + props: { indent: 0 }, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "item-2", + offset: 0, + text: "Beta", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am converting the bullet list into a numbered list.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "item-2"], + }, + markdown: ["1. First", "2. Second"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "convert-block", + blockId: firstId, + newType: "numberedListItem", + newProps: { indent: 0, start: 1 }, + }, + { + type: "replace-text", + blockId: firstId, + offset: 0, + length: "Alpha".length, + text: "First", + }, + { + type: "convert-block", + blockId: "item-2", + newType: "numberedListItem", + newProps: { indent: 0, start: undefined }, + }, + { + type: "replace-text", + blockId: "item-2", + offset: 0, + length: "Beta".length, + text: "Second", + }, + ]); + }); + + it("reuses matching suffix blocks when a flow patch inserts at the front", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Keep second", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new heading before the existing paragraphs.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2"], + }, + markdown: ["## New intro", "", "Keep first", "", "Keep second"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "heading", + props: { level: 2 }, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "New intro", + }, + ]); + }); +}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts new file mode 100644 index 0000000..90ae75d --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part2.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { createPlanExecutorEditor } from "./planExecutor.testUtils"; + +describe("document mutation plan executor", () => { + it("reuses matching prefix blocks when a flow patch deletes at the end", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Keep second", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Remove me", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am trimming the trailing paragraph.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Keep first", "", "Keep second"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "delete-block", + blockId: "block-3", + }, + ]); + }); + + it("reuses and rewrites a near-match suffix block during front insertions", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Final thoughts", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new intro and lightly revising the ending.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2"], + }, + markdown: [ + "New intro", + "", + "Keep first", + "", + "Final thoughts updated", + ].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "New intro", + }, + { + type: "replace-text", + blockId: "block-2", + offset: 0, + length: "Final thoughts".length, + text: "Final thoughts updated", + }, + ]); + }); + + it("reuses and reformats a suffix block when inline marks are added", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Final thoughts", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new intro and bolding the ending.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2"], + }, + markdown: [ + "New intro", + "", + "Keep first", + "", + "**Final thoughts**", + ].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "New intro", + }, + { + type: "replace-text", + blockId: "block-2", + offset: 0, + length: "Final thoughts".length, + text: "Final thoughts", + }, + { + type: "format-text", + blockId: "block-2", + offset: 0, + length: "Final thoughts".length, + marks: { bold: true }, + }, + ]); + }); + + it("reuses block ids when a flow patch inserts in the middle", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Bravo", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am inserting a new paragraph between Bravo and Charlie.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Alpha", "", "Bravo", "", "Inserted middle", "", "Charlie"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "Inserted middle", + }, + ]); + expect(execution.metrics?.flowPatchAlignment).toEqual({ + preservedBlockCount: 3, + rewrittenBlockCount: 0, + unchangedBlockCount: 3, + insertedBlockCount: 1, + deletedBlockCount: 0, + estimatedOperationCost: 2, + }); + }); + + it("reuses block ids when a flow patch deletes in the middle", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Remove me", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Charlie", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am deleting the middle paragraph.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Alpha", "", "Charlie"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "delete-block", + blockId: "block-2", + }, + ]); + expect(execution.metrics?.flowPatchAlignment).toEqual({ + preservedBlockCount: 2, + rewrittenBlockCount: 0, + unchangedBlockCount: 2, + insertedBlockCount: 0, + deletedBlockCount: 1, + estimatedOperationCost: 1, + }); + }); +}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts new file mode 100644 index 0000000..c7374cd --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.part3.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "vitest"; +import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { createPlanExecutorEditor } from "./planExecutor.testUtils"; + +describe("document mutation plan executor", () => { + it("prefers the lower-op middle alignment when repeated blocks create multiple match options", () => { + const editor = createPlanExecutorEditor(); + const firstId = editor.firstBlock()!.id; + editor.apply( + [ + { + type: "convert-block", + blockId: firstId, + newType: "heading", + newProps: { level: 1 }, + }, + { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, + { + type: "insert-block", + blockId: "block-2", + blockType: "paragraph", + props: {}, + position: { after: firstId }, + }, + { + type: "insert-text", + blockId: "block-2", + offset: 0, + text: "Note", + }, + { + type: "insert-block", + blockId: "block-3", + blockType: "paragraph", + props: {}, + position: { after: "block-2" }, + }, + { + type: "insert-text", + blockId: "block-3", + offset: 0, + text: "Omega", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "flow_patch", + instructions: "I am moving a revised note before Alpha while keeping Omega.", + scope: "adjacent-blocks", + targetSpanId: `span:${firstId}`, + edits: [ + { + operation: "replace_blocks", + locator: { + blockIds: [firstId, "block-2", "block-3"], + }, + markdown: ["Note updated", "", "# Alpha", "", "Omega"].join("\n"), + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.reviewSafe).toBe(true); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: expect.any(String), + blockType: "paragraph", + props: {}, + position: { before: firstId }, + }, + { + type: "insert-text", + blockId: expect.any(String), + offset: 0, + text: "Note updated", + }, + { + type: "delete-block", + blockId: "block-2", + }, + ]); + expect(execution.metrics?.flowPatchAlignment).toEqual({ + preservedBlockCount: 2, + rewrittenBlockCount: 0, + unchangedBlockCount: 2, + insertedBlockCount: 1, + deletedBlockCount: 1, + estimatedOperationCost: 3, + }); + }); + + it("builds database ops and stringifies database values", () => { + const editor = createPlanExecutorEditor(); + editor.apply( + [{ + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: "last", + }], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "database_edit", + blockId: "database-1", + steps: [ + { + op: "insert_row", + rowId: "row-1", + values: { done: true, count: 3 }, + }, + { + op: "update_cell", + rowId: "row-1", + columnId: "name", + value: { label: "Ship" }, + }, + ], + }); + + expect(execution.reviewSafe).toBe(false); + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "database-insert-row", + blockId: "database-1", + rowId: "row-1", + values: { done: "true", count: "3" }, + }, + { + type: "database-update-cell", + blockId: "database-1", + rowId: "row-1", + columnId: "name", + value: JSON.stringify({ label: "Ship" }), + }, + ]); + }); + + it("marks review bundles as not review-safe when they contain database edits", () => { + const editor = createPlanExecutorEditor(); + editor.apply( + [ + { + type: "insert-block", + blockId: "database-1", + blockType: "database", + props: {}, + position: "last", + }, + ], + { origin: "system" }, + ); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Review", + reason: "Bundle", + plans: [ + { + kind: "block_insert", + blockType: "paragraph", + position: "last", + initialText: "Hello", + }, + { + kind: "database_edit", + blockId: "database-1", + steps: [{ op: "set_active_view", viewId: "view-1" }], + }, + ], + }); + + expect(execution.reviewSafe).toBe(false); + expect(execution.issues).toEqual([]); + expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); + expect( + execution.ops.some((op) => op.type === "database-set-active-view"), + ).toBe(true); + }); + + it("supports review bundles that insert then update and edit a regular block", () => { + const editor = createPlanExecutorEditor(); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Create heading", + reason: "Insert, refine props, and edit text.", + plans: [ + { + kind: "block_insert", + blockId: "heading-new", + blockType: "paragraph", + position: "last", + initialText: "Draft", + }, + { + kind: "block_update", + blockId: "heading-new", + props: { tone: "title" }, + }, + { + kind: "text_edit", + target: { + blockId: "heading-new", + range: { + startOffset: 0, + endOffset: 5, + }, + }, + operation: "replace", + text: "Final", + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: "heading-new", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "heading-new", + offset: 0, + text: "Draft", + }, + { + type: "update-block", + blockId: "heading-new", + props: { tone: "title" }, + }, + { + type: "replace-text", + blockId: "heading-new", + offset: 0, + length: 5, + text: "Final", + }, + ]); + }); + + it("supports review bundles that insert then convert a regular block", () => { + const editor = createPlanExecutorEditor(); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Create heading", + reason: "Insert then convert the new block.", + plans: [ + { + kind: "block_insert", + blockId: "heading-new", + blockType: "paragraph", + position: "last", + initialText: "Hello", + }, + { + kind: "block_convert", + blockId: "heading-new", + newType: "heading", + props: { level: 2 }, + }, + ], + }); + + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: "heading-new", + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: "heading-new", + offset: 0, + text: "Hello", + }, + { + type: "convert-block", + blockId: "heading-new", + newType: "heading", + newProps: { level: 2 }, + }, + ]); + }); + + it("supports review bundles that insert and then populate a database", () => { + const editor = createPlanExecutorEditor(); + + const execution = buildDocumentMutationPlanExecution(editor, { + kind: "review_bundle", + label: "Create people database", + reason: "Insert and populate a new database.", + plans: [ + { + kind: "block_insert", + blockId: "database-new", + blockType: "database", + position: "last", + }, + { + kind: "database_edit", + blockId: "database-new", + steps: [ + { + op: "add_column", + column: { id: "name", title: "Name", type: "text" }, + }, + { + op: "insert_row", + rowId: "row-1", + values: { name: "Alice" }, + }, + ], + }, + ], + }); + + expect(execution.reviewSafe).toBe(false); + expect(execution.issues).toEqual([]); + expect(execution.ops).toEqual([ + { + type: "insert-block", + blockId: "database-new", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-add-column", + blockId: "database-new", + column: { id: "name", title: "Name", type: "text" }, + }, + { + type: "database-insert-row", + blockId: "database-new", + rowId: "row-1", + values: { name: "Alice" }, + }, + ]); + }); +}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts index 2fbca03..db5cd01 100644 --- a/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.test.ts @@ -1,1065 +1,3 @@ -import { describe, expect, it } from "vitest"; -import { createEditor } from "@pen/core"; -import { buildDocumentMutationPlanExecution } from "../planExecutor"; +import { describe } from "vitest"; -const noDefaultExtensionsPreset = { - resolve() { - return { extensions: [] }; - }, -}; - -function createPlanExecutorEditor() { - return createEditor({ - preset: noDefaultExtensionsPreset, - }); -} - -describe("document mutation plan executor", () => { - it("builds replace-text ops for text edit plans", () => { - const editor = createPlanExecutorEditor(); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "text_edit", - target: { - blockId, - range: { - startOffset: 6, - endOffset: 11, - }, - }, - operation: "replace", - text: "planet", - }); - - expect(execution.reviewSafe).toBe(true); - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "replace-text", - blockId, - offset: 6, - length: 5, - text: "planet", - }, - ]); - }); - - it("builds native ops for flow patch plans", () => { - const editor = createPlanExecutorEditor(); - const firstBlockId = editor.firstBlock()!.id; - editor.apply( - [{ - type: "replace-text", - blockId: firstBlockId, - offset: 0, - length: 0, - text: "Alpha", - }], - { origin: "system" }, - ); - editor.apply( - [{ - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstBlockId }, - }, { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Bravo", - }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am updating the current paragraph and inserting a heading after it.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstBlockId}`, - edits: [ - { - operation: "replace_text", - locator: { - blockId: firstBlockId, - expectedBlockType: "paragraph", - }, - text: "Alpha updated", - }, - { - operation: "insert_after", - locator: { - blockId: "block-2", - }, - markdown: "## Next step", - }, - ], - }); - - expect(execution.reviewSafe).toBe(true); - expect(execution.issues).toEqual([]); - expect(execution.ops[0]).toEqual({ - type: "replace-text", - blockId: firstBlockId, - offset: 0, - length: 5, - text: "Alpha updated", - }); - expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); - expect(execution.ops.some((op) => op.type === "insert-text")).toBe(true); - }); - - it("optimizes single-block markdown replacements into native ops", () => { - const editor = createPlanExecutorEditor(); - const blockId = editor.firstBlock()!.id; - editor.apply( - [{ type: "insert-text", blockId, offset: 0, text: "Old title" }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am turning the paragraph into a heading with new copy.", - scope: "single-block", - targetSpanId: `span:${blockId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [blockId], - }, - markdown: "## New title", - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "convert-block", - blockId, - newType: "heading", - newProps: { level: 2 }, - }, - { - type: "replace-text", - blockId, - offset: 0, - length: "Old title".length, - text: "New title", - }, - ]); - }); - - it("optimizes adjacent multi-block markdown replacements into native ops", () => { - const editor = createPlanExecutorEditor(); - const headingId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "convert-block", blockId: headingId, newType: "heading", newProps: { level: 1 } }, - { type: "insert-text", blockId: headingId, offset: 0, text: "Old heading" }, - { - type: "insert-block", - blockId: "paragraph-2", - blockType: "paragraph", - props: {}, - position: { after: headingId }, - }, - { - type: "insert-text", - blockId: "paragraph-2", - offset: 0, - text: "Old body", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am rewriting the heading and paragraph together.", - scope: "adjacent-blocks", - targetSpanId: `span:${headingId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [headingId, "paragraph-2"], - }, - markdown: ["## New heading", "", "New body copy"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "update-block", - blockId: headingId, - props: { level: 2 }, - }, - { - type: "replace-text", - blockId: headingId, - offset: 0, - length: "Old heading".length, - text: "New heading", - }, - { - type: "replace-text", - blockId: "paragraph-2", - offset: 0, - length: "Old body".length, - text: "New body copy", - }, - ]); - }); - - it("optimizes adjacent list rewrites into native ops", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "convert-block", blockId: firstId, newType: "bulletListItem", newProps: { indent: 0 } }, - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "item-2", - blockType: "bulletListItem", - props: { indent: 0 }, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "item-2", - offset: 0, - text: "Beta", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am converting the bullet list into a numbered list.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "item-2"], - }, - markdown: ["1. First", "2. Second"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "convert-block", - blockId: firstId, - newType: "numberedListItem", - newProps: { indent: 0, start: 1 }, - }, - { - type: "replace-text", - blockId: firstId, - offset: 0, - length: "Alpha".length, - text: "First", - }, - { - type: "convert-block", - blockId: "item-2", - newType: "numberedListItem", - newProps: { indent: 0, start: undefined }, - }, - { - type: "replace-text", - blockId: "item-2", - offset: 0, - length: "Beta".length, - text: "Second", - }, - ]); - }); - - it("reuses matching suffix blocks when a flow patch inserts at the front", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Keep second", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new heading before the existing paragraphs.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2"], - }, - markdown: ["## New intro", "", "Keep first", "", "Keep second"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "heading", - props: { level: 2 }, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "New intro", - }, - ]); - }); - - it("reuses matching prefix blocks when a flow patch deletes at the end", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Keep second", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Remove me", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am trimming the trailing paragraph.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Keep first", "", "Keep second"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "delete-block", - blockId: "block-3", - }, - ]); - }); - - it("reuses and rewrites a near-match suffix block during front insertions", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Final thoughts", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new intro and lightly revising the ending.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2"], - }, - markdown: [ - "New intro", - "", - "Keep first", - "", - "Final thoughts updated", - ].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "New intro", - }, - { - type: "replace-text", - blockId: "block-2", - offset: 0, - length: "Final thoughts".length, - text: "Final thoughts updated", - }, - ]); - }); - - it("reuses and reformats a suffix block when inline marks are added", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Keep first" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Final thoughts", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new intro and bolding the ending.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2"], - }, - markdown: [ - "New intro", - "", - "Keep first", - "", - "**Final thoughts**", - ].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "New intro", - }, - { - type: "replace-text", - blockId: "block-2", - offset: 0, - length: "Final thoughts".length, - text: "Final thoughts", - }, - { - type: "format-text", - blockId: "block-2", - offset: 0, - length: "Final thoughts".length, - marks: { bold: true }, - }, - ]); - }); - - it("reuses block ids when a flow patch inserts in the middle", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Bravo", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am inserting a new paragraph between Bravo and Charlie.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Alpha", "", "Bravo", "", "Inserted middle", "", "Charlie"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "Inserted middle", - }, - ]); - expect(execution.metrics?.flowPatchAlignment).toEqual({ - preservedBlockCount: 3, - rewrittenBlockCount: 0, - unchangedBlockCount: 3, - insertedBlockCount: 1, - deletedBlockCount: 0, - estimatedOperationCost: 2, - }); - }); - - it("reuses block ids when a flow patch deletes in the middle", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Remove me", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Charlie", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am deleting the middle paragraph.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Alpha", "", "Charlie"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "delete-block", - blockId: "block-2", - }, - ]); - expect(execution.metrics?.flowPatchAlignment).toEqual({ - preservedBlockCount: 2, - rewrittenBlockCount: 0, - unchangedBlockCount: 2, - insertedBlockCount: 0, - deletedBlockCount: 1, - estimatedOperationCost: 1, - }); - }); - - it("prefers the lower-op middle alignment when repeated blocks create multiple match options", () => { - const editor = createPlanExecutorEditor(); - const firstId = editor.firstBlock()!.id; - editor.apply( - [ - { - type: "convert-block", - blockId: firstId, - newType: "heading", - newProps: { level: 1 }, - }, - { type: "insert-text", blockId: firstId, offset: 0, text: "Alpha" }, - { - type: "insert-block", - blockId: "block-2", - blockType: "paragraph", - props: {}, - position: { after: firstId }, - }, - { - type: "insert-text", - blockId: "block-2", - offset: 0, - text: "Note", - }, - { - type: "insert-block", - blockId: "block-3", - blockType: "paragraph", - props: {}, - position: { after: "block-2" }, - }, - { - type: "insert-text", - blockId: "block-3", - offset: 0, - text: "Omega", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "flow_patch", - instructions: "I am moving a revised note before Alpha while keeping Omega.", - scope: "adjacent-blocks", - targetSpanId: `span:${firstId}`, - edits: [ - { - operation: "replace_blocks", - locator: { - blockIds: [firstId, "block-2", "block-3"], - }, - markdown: ["Note updated", "", "# Alpha", "", "Omega"].join("\n"), - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.reviewSafe).toBe(true); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: expect.any(String), - blockType: "paragraph", - props: {}, - position: { before: firstId }, - }, - { - type: "insert-text", - blockId: expect.any(String), - offset: 0, - text: "Note updated", - }, - { - type: "delete-block", - blockId: "block-2", - }, - ]); - expect(execution.metrics?.flowPatchAlignment).toEqual({ - preservedBlockCount: 2, - rewrittenBlockCount: 0, - unchangedBlockCount: 2, - insertedBlockCount: 1, - deletedBlockCount: 1, - estimatedOperationCost: 3, - }); - }); - - it("builds database ops and stringifies database values", () => { - const editor = createPlanExecutorEditor(); - editor.apply( - [{ - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: "last", - }], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "database_edit", - blockId: "database-1", - steps: [ - { - op: "insert_row", - rowId: "row-1", - values: { done: true, count: 3 }, - }, - { - op: "update_cell", - rowId: "row-1", - columnId: "name", - value: { label: "Ship" }, - }, - ], - }); - - expect(execution.reviewSafe).toBe(false); - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "database-insert-row", - blockId: "database-1", - rowId: "row-1", - values: { done: "true", count: "3" }, - }, - { - type: "database-update-cell", - blockId: "database-1", - rowId: "row-1", - columnId: "name", - value: JSON.stringify({ label: "Ship" }), - }, - ]); - }); - - it("marks review bundles as not review-safe when they contain database edits", () => { - const editor = createPlanExecutorEditor(); - editor.apply( - [ - { - type: "insert-block", - blockId: "database-1", - blockType: "database", - props: {}, - position: "last", - }, - ], - { origin: "system" }, - ); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Review", - reason: "Bundle", - plans: [ - { - kind: "block_insert", - blockType: "paragraph", - position: "last", - initialText: "Hello", - }, - { - kind: "database_edit", - blockId: "database-1", - steps: [{ op: "set_active_view", viewId: "view-1" }], - }, - ], - }); - - expect(execution.reviewSafe).toBe(false); - expect(execution.issues).toEqual([]); - expect(execution.ops.some((op) => op.type === "insert-block")).toBe(true); - expect( - execution.ops.some((op) => op.type === "database-set-active-view"), - ).toBe(true); - }); - - it("supports review bundles that insert then update and edit a regular block", () => { - const editor = createPlanExecutorEditor(); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Create heading", - reason: "Insert, refine props, and edit text.", - plans: [ - { - kind: "block_insert", - blockId: "heading-new", - blockType: "paragraph", - position: "last", - initialText: "Draft", - }, - { - kind: "block_update", - blockId: "heading-new", - props: { tone: "title" }, - }, - { - kind: "text_edit", - target: { - blockId: "heading-new", - range: { - startOffset: 0, - endOffset: 5, - }, - }, - operation: "replace", - text: "Final", - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: "heading-new", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "heading-new", - offset: 0, - text: "Draft", - }, - { - type: "update-block", - blockId: "heading-new", - props: { tone: "title" }, - }, - { - type: "replace-text", - blockId: "heading-new", - offset: 0, - length: 5, - text: "Final", - }, - ]); - }); - - it("supports review bundles that insert then convert a regular block", () => { - const editor = createPlanExecutorEditor(); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Create heading", - reason: "Insert then convert the new block.", - plans: [ - { - kind: "block_insert", - blockId: "heading-new", - blockType: "paragraph", - position: "last", - initialText: "Hello", - }, - { - kind: "block_convert", - blockId: "heading-new", - newType: "heading", - props: { level: 2 }, - }, - ], - }); - - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: "heading-new", - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: "heading-new", - offset: 0, - text: "Hello", - }, - { - type: "convert-block", - blockId: "heading-new", - newType: "heading", - newProps: { level: 2 }, - }, - ]); - }); - - it("supports review bundles that insert and then populate a database", () => { - const editor = createPlanExecutorEditor(); - - const execution = buildDocumentMutationPlanExecution(editor, { - kind: "review_bundle", - label: "Create people database", - reason: "Insert and populate a new database.", - plans: [ - { - kind: "block_insert", - blockId: "database-new", - blockType: "database", - position: "last", - }, - { - kind: "database_edit", - blockId: "database-new", - steps: [ - { - op: "add_column", - column: { id: "name", title: "Name", type: "text" }, - }, - { - op: "insert_row", - rowId: "row-1", - values: { name: "Alice" }, - }, - ], - }, - ], - }); - - expect(execution.reviewSafe).toBe(false); - expect(execution.issues).toEqual([]); - expect(execution.ops).toEqual([ - { - type: "insert-block", - blockId: "database-new", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-add-column", - blockId: "database-new", - column: { id: "name", title: "Name", type: "text" }, - }, - { - type: "database-insert-row", - blockId: "database-new", - rowId: "row-1", - values: { name: "Alice" }, - }, - ]); - }); -}); +describe.skip("document mutation plan executor split entrypoint", () => {}); diff --git a/packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts b/packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts new file mode 100644 index 0000000..c426204 --- /dev/null +++ b/packages/extensions/ai/src/runtime/__tests__/planExecutor.testUtils.ts @@ -0,0 +1,13 @@ +import { createEditor } from "@pen/core"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +export function createPlanExecutorEditor() { + return createEditor({ + preset: noDefaultExtensionsPreset, + }); +} diff --git a/packages/extensions/ai/src/runtime/externalInlineTurnRegistry.ts b/packages/extensions/ai/src/runtime/externalInlineTurnRegistry.ts new file mode 100644 index 0000000..3612d86 --- /dev/null +++ b/packages/extensions/ai/src/runtime/externalInlineTurnRegistry.ts @@ -0,0 +1,107 @@ +import type { DocumentOp } from "@pen/types"; +import type { + AIExternalInlineTurnResult, + AIInlineHistoryDirection, + AIInlineHistorySnapshot, +} from "../types"; + +export interface StoredExternalInlineTurnResult extends AIExternalInlineTurnResult { + beforeSnapshotId?: string; + afterSnapshotId?: string; +} + +export class ExternalInlineTurnRegistry { + private readonly results = new Map(); + + has(historyId: string): boolean { + return this.results.has(historyId); + } + + get(historyId: string): StoredExternalInlineTurnResult | undefined { + return this.results.get(historyId); + } + + set( + historyId: string, + result: StoredExternalInlineTurnResult, + ): void { + this.results.set(historyId, result); + } + + values(): StoredExternalInlineTurnResult[] { + return [...this.results.values()]; + } + + resolveTransition( + currentSnapshot: AIInlineHistorySnapshot | null, + targetSnapshot: AIInlineHistorySnapshot, + direction: AIInlineHistoryDirection, + snapshotHasTurn: ( + snapshot: AIInlineHistorySnapshot, + sessionId: string, + turnId: string, + ) => boolean, + ): StoredExternalInlineTurnResult | null { + if (!currentSnapshot) { + return null; + } + + const results = [...this.results.values()].reverse(); + for (const result of results) { + const currentHasTurn = snapshotHasTurn( + currentSnapshot, + result.sessionId, + result.turnId, + ); + const targetHasTurn = snapshotHasTurn( + targetSnapshot, + result.sessionId, + result.turnId, + ); + if ( + direction === "undo" && + ((result.afterSnapshotId === currentSnapshot.id && + result.beforeSnapshotId === targetSnapshot.id) || + (currentHasTurn && !targetHasTurn)) + ) { + return result; + } + if ( + direction === "redo" && + ((result.beforeSnapshotId === currentSnapshot.id && + result.afterSnapshotId === targetSnapshot.id) || + (!currentHasTurn && targetHasTurn)) + ) { + return result; + } + } + + return null; + } + + turnHasExternalResult(sessionId: string, turnId: string): boolean { + return this.values().some( + (result) => result.sessionId === sessionId && result.turnId === turnId, + ); + } +} + +export type RegisterExternalInlineTurnInput = { + sessionId: string; + turnId: string; + historyId: string; + operations: readonly DocumentOp[]; + suggestionIds: readonly string[]; +}; + +export function canRegisterExternalInlineTurn( + input: RegisterExternalInlineTurnInput, + registry: ExternalInlineTurnRegistry, +): boolean { + return ( + Boolean(input.historyId) && + input.operations.length > 0 && + input.suggestionIds.length > 0 && + !registry.has(input.historyId) + ); +} diff --git a/packages/extensions/ai/src/runtime/planExecutor.ts b/packages/extensions/ai/src/runtime/planExecutor.ts index 575f367..52886e1 100644 --- a/packages/extensions/ai/src/runtime/planExecutor.ts +++ b/packages/extensions/ai/src/runtime/planExecutor.ts @@ -1,1361 +1,2 @@ -import type { DocumentOp, Editor } from "@pen/types"; -import { buildDocumentWriteOps } from "@pen/document-ops"; -import { generateId } from "@pen/types"; -import type { - BlockConvertPlan, - BlockInsertPlan, - BlockMovePlan, - BlockUpdatePlan, - DatabaseEditPlan, - DocumentMutationPlan, - FlowPatchEdit, - FlowPatchPlan, - ReviewBundlePlan, - TextEditPlan, -} from "./planTypes"; - -export interface PlanExecutionIssue { - path: string; - code: - | "missing-block" - | "invalid-target" - | "unsupported-target" - | "invalid-range"; - message: string; -} - -export interface PlanExecutionResult { - ops: DocumentOp[]; - issues: PlanExecutionIssue[]; - reviewSafe: boolean; - metrics?: PlanExecutionMetrics; -} - -export interface PlanExecutionMetrics { - flowPatchAlignment?: FlowPatchAlignmentMetrics; -} - -export interface FlowPatchAlignmentMetrics { - preservedBlockCount: number; - rewrittenBlockCount: number; - unchangedBlockCount: number; - insertedBlockCount: number; - deletedBlockCount: number; - estimatedOperationCost: number; -} - -interface VirtualBlockState { - type: string; - props: Record; - textLength: number; - database?: { - columnIds: Set; - rowIds: Set; - viewIds: Set; - }; -} - -interface PlanExecutionContext { - virtualBlocks: Map; -} - -interface PendingInlineMark { - type: string; - props?: Record; - start: number; - end: number; -} - -interface PendingInlineBlock { - type: string; - props: Record; - content?: string; - marks?: PendingInlineMark[]; - children?: unknown[]; - database?: unknown; -} - -interface InlineAlignmentStep { - kind: "substitute" | "insert" | "delete"; - targetIndex?: number; - parsedIndex?: number; -} - -interface InlineAlignmentResolution { - steps: InlineAlignmentStep[]; - metrics: FlowPatchAlignmentMetrics; -} - -export function buildDocumentMutationPlanExecution( - editor: Editor, - plan: DocumentMutationPlan, -): PlanExecutionResult { - const context: PlanExecutionContext = { - virtualBlocks: new Map(), - }; - return buildPlanExecution(editor, plan, context); -} - -function buildPlanExecution( - editor: Editor, - plan: DocumentMutationPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - switch (plan.kind) { - case "text_edit": - return buildTextEditExecution(editor, plan, context); - case "flow_patch": - return buildFlowPatchExecution(editor, plan); - case "block_insert": - return buildBlockInsertExecution(editor, plan, context); - case "block_update": - return buildBlockUpdateExecution(editor, plan, context); - case "block_move": - return buildBlockMoveExecution(editor, plan, context); - case "block_convert": - return buildBlockConvertExecution(editor, plan, context); - case "database_edit": - return buildDatabaseEditExecution(editor, plan, context); - case "review_bundle": - return buildReviewBundleExecution(editor, plan, context); - } -} - -function buildTextEditExecution( - editor: Editor, - plan: TextEditPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockState = resolveBlockState(editor, context, plan.target.blockId); - if (!blockState) { - return withIssue( - `${plan.kind}.target.blockId`, - "missing-block", - `Block "${plan.target.blockId}" was not found.`, - ); - } - - const blockLength = blockState.textLength; - if ( - plan.target.range && - (plan.target.range.startOffset < 0 || - plan.target.range.endOffset < plan.target.range.startOffset || - plan.target.range.endOffset > blockLength) - ) { - return withIssue( - `${plan.kind}.target.range`, - "invalid-range", - "Text edit range is outside the target block.", - ); - } - - if (plan.operation === "append") { - context.virtualBlocks.set(plan.target.blockId, { - ...blockState, - textLength: blockLength + plan.text.length, - }); - return { - ops: [{ - type: "insert-text", - blockId: plan.target.blockId, - offset: blockLength, - text: plan.text, - }], - issues: [], - reviewSafe: true, - }; - } - - if (plan.operation === "insert") { - const offset = plan.target.range?.startOffset ?? blockLength; - context.virtualBlocks.set(plan.target.blockId, { - ...blockState, - textLength: blockLength + plan.text.length, - }); - return { - ops: [{ - type: "insert-text", - blockId: plan.target.blockId, - offset, - text: plan.text, - }], - issues: [], - reviewSafe: true, - }; - } - - const offset = plan.target.range?.startOffset ?? 0; - const length = - plan.target.range != null - ? plan.target.range.endOffset - plan.target.range.startOffset - : blockLength; - context.virtualBlocks.set(plan.target.blockId, { - ...blockState, - textLength: blockLength - length + plan.text.length, - }); - - return { - ops: [{ - type: "replace-text", - blockId: plan.target.blockId, - offset, - length, - text: plan.text, - }], - issues: [], - reviewSafe: true, - }; -} - -function buildFlowPatchExecution( - editor: Editor, - plan: FlowPatchPlan, -): PlanExecutionResult { - const ops: DocumentOp[] = []; - const issues: PlanExecutionIssue[] = []; - let reviewSafe = true; - let flowPatchAlignmentMetrics: FlowPatchAlignmentMetrics | undefined; - - for (const [index, edit] of plan.edits.entries()) { - const execution = buildFlowPatchEditExecution(editor, edit, `${plan.kind}.edits[${index}]`); - ops.push(...execution.ops); - issues.push(...execution.issues); - reviewSafe = reviewSafe && execution.reviewSafe; - flowPatchAlignmentMetrics = mergeFlowPatchAlignmentMetrics( - flowPatchAlignmentMetrics, - execution.metrics?.flowPatchAlignment, - ); - } - - return { - ops, - issues, - reviewSafe, - metrics: - flowPatchAlignmentMetrics == null - ? undefined - : { flowPatchAlignment: flowPatchAlignmentMetrics }, - }; -} - -function buildBlockInsertExecution( - editor: Editor, - plan: BlockInsertPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockId = plan.blockId ?? generateId(); - if (resolveBlockState(editor, context, blockId)) { - return withIssue( - `${plan.kind}.blockId`, - "invalid-target", - `Block "${blockId}" already exists.`, - ); - } - - context.virtualBlocks.set( - blockId, - createVirtualBlockState( - plan.blockType, - plan.props ?? {}, - plan.initialText ?? "", - ), - ); - const ops: DocumentOp[] = [{ - type: "insert-block", - blockId, - blockType: plan.blockType, - props: plan.props ?? {}, - position: plan.position, - }]; - - if (plan.initialText && plan.initialText.length > 0) { - ops.push({ - type: "insert-text", - blockId, - offset: 0, - text: plan.initialText, - }); - } - - return { - ops, - issues: [], - reviewSafe: true, - }; -} - -function buildFlowPatchEditExecution( - editor: Editor, - edit: FlowPatchEdit, - path: string, -): PlanExecutionResult { - const targetBlockIds = - edit.locator.blockIds?.filter((blockId) => blockId.length > 0) ?? - (edit.locator.blockId ? [edit.locator.blockId] : []); - const primaryBlockId = targetBlockIds[0] ?? null; - const primaryBlock = primaryBlockId ? editor.getBlock(primaryBlockId) : null; - - if ( - edit.locator.expectedBlockType && - primaryBlock && - primaryBlock.type !== edit.locator.expectedBlockType - ) { - return withIssue( - `${path}.locator.expectedBlockType`, - "unsupported-target", - `Block "${primaryBlock.id}" is "${primaryBlock.type}", expected "${edit.locator.expectedBlockType}".`, - ); - } - - switch (edit.operation) { - case "replace_text": { - if (!primaryBlockId || !primaryBlock) { - return withIssue( - `${path}.locator.blockId`, - "missing-block", - "Flow patch replace_text requires an existing target block.", - ); - } - return { - ops: [{ - type: "replace-text", - blockId: primaryBlockId, - offset: 0, - length: primaryBlock.length(), - text: edit.text ?? "", - }], - issues: [], - reviewSafe: true, - }; - } - case "append_text": { - if (!primaryBlockId || !primaryBlock) { - return withIssue( - `${path}.locator.blockId`, - "missing-block", - "Flow patch append_text requires an existing target block.", - ); - } - return { - ops: [{ - type: "insert-text", - blockId: primaryBlockId, - offset: primaryBlock.length(), - text: edit.text ?? "", - }], - issues: [], - reviewSafe: true, - }; - } - case "insert_before": - case "insert_after": { - if (!primaryBlockId || !primaryBlock) { - return withIssue( - `${path}.locator.blockId`, - "missing-block", - `Flow patch ${edit.operation} requires an existing target block.`, - ); - } - const { ops } = buildDocumentWriteOps(editor, { - format: "markdown", - content: edit.markdown ?? "", - position: - edit.operation === "insert_before" - ? { before: primaryBlockId } - : { after: primaryBlockId }, - surface: "ai-flow-patch", - }); - return { - ops, - issues: [], - reviewSafe: true, - }; - } - case "replace_blocks": { - if (targetBlockIds.length === 0) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch replace_blocks requires one or more target blocks.", - ); - } - if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch replace_blocks targets a missing block.", - ); - } - const optimized = buildOptimizedBlockReplacement( - editor, - targetBlockIds, - edit.markdown ?? "", - ); - if (optimized) { - return optimized; - } - const { ops } = buildDocumentWriteOps(editor, { - format: "markdown", - content: edit.markdown ?? "", - position: { before: targetBlockIds[0]! }, - surface: "ai-flow-patch", - }); - return { - ops: [ - ...ops, - ...targetBlockIds.map((blockId) => ({ - type: "delete-block", - blockId, - }) satisfies DocumentOp), - ], - issues: [], - reviewSafe: true, - }; - } - case "delete_blocks": { - if (targetBlockIds.length === 0) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch delete_blocks requires one or more target blocks.", - ); - } - if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { - return withIssue( - `${path}.locator.blockIds`, - "missing-block", - "Flow patch delete_blocks targets a missing block.", - ); - } - return { - ops: targetBlockIds.map((blockId) => ({ - type: "delete-block", - blockId, - }) satisfies DocumentOp), - issues: [], - reviewSafe: true, - }; - } - } -} - -function buildOptimizedBlockReplacement( - editor: Editor, - targetBlockIds: string[], - markdown: string, -): PlanExecutionResult | null { - if (targetBlockIds.length === 0) { - return null; - } - - const targetBlocks = targetBlockIds - .map((blockId) => editor.getBlock(blockId)) - .filter((block): block is NonNullable => block != null); - if (targetBlocks.length !== targetBlockIds.length) { - return null; - } - - const parsedBlocks = buildDocumentWriteOps(editor, { - format: "markdown", - content: markdown, - surface: "ai-flow-patch-optimize", - }).blocks as PendingInlineBlock[]; - if ( - parsedBlocks.some((parsedBlock) => !isInlineConvertiblePendingBlock(parsedBlock)) - ) { - return null; - } - if (targetBlocks.some((block) => !isInlineConvertibleTargetBlock(block))) { - return null; - } - - const alignment = resolveInlineAlignmentPlan(targetBlocks, parsedBlocks); - const ops = buildInlineAlignmentOps(alignment.steps, targetBlocks, parsedBlocks); - - return { - ops, - issues: [], - reviewSafe: true, - metrics: { - flowPatchAlignment: alignment.metrics, - }, - }; -} - -function buildInlineBlockRewriteOps( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): DocumentOp[] { - const ops: DocumentOp[] = []; - if (parsedBlock.type !== targetBlock.type) { - ops.push({ - type: "convert-block", - blockId: targetBlock.id, - newType: parsedBlock.type, - newProps: parsedBlock.props, - }); - } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { - ops.push({ - type: "update-block", - blockId: targetBlock.id, - props: parsedBlock.props, - }); - } - - const nextText = parsedBlock.content ?? ""; - const needsTextRewrite = - targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0; - if (needsTextRewrite) { - ops.push({ - type: "replace-text", - blockId: targetBlock.id, - offset: 0, - length: targetBlock.length(), - text: nextText, - }); - for (const mark of parsedBlock.marks ?? []) { - if (mark.end <= mark.start) { - continue; - } - ops.push({ - type: "format-text", - blockId: targetBlock.id, - offset: mark.start, - length: mark.end - mark.start, - marks: { [mark.type]: mark.props ?? true }, - }); - } - } - - return ops; -} - -function buildInlineAlignmentOps( - alignment: InlineAlignmentStep[], - targetBlocks: Array>>, - parsedBlocks: PendingInlineBlock[], -): DocumentOp[] { - const ops: DocumentOp[] = []; - const pendingInserts: PendingInlineBlock[] = []; - let blockBefore: string | null = null; - - for (const step of alignment) { - if (step.kind === "insert") { - pendingInserts.push(parsedBlocks[step.parsedIndex!]!); - continue; - } - - if (step.kind === "substitute") { - const targetBlock = targetBlocks[step.targetIndex!]!; - if (pendingInserts.length > 0) { - const insertOps = buildInlinePendingBlockInsertOps( - pendingInserts, - resolveInsertionPosition(blockBefore, targetBlock.id), - ); - ops.push(...insertOps); - blockBefore = resolveLastInsertedBlockId(insertOps) ?? blockBefore; - pendingInserts.length = 0; - } - ops.push( - ...buildInlineBlockRewriteOps( - targetBlock, - parsedBlocks[step.parsedIndex!]!, - ), - ); - blockBefore = targetBlock.id; - continue; - } - - ops.push({ - type: "delete-block", - blockId: targetBlocks[step.targetIndex!]!.id, - }); - } - - if (pendingInserts.length > 0) { - ops.push( - ...buildInlinePendingBlockInsertOps( - pendingInserts, - resolveInsertionPosition(blockBefore, null), - ), - ); - } - - return ops; -} - -function buildBlockUpdateExecution( - editor: Editor, - plan: BlockUpdatePlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockState = resolveBlockState(editor, context, plan.blockId); - if (!blockState) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - context.virtualBlocks.set(plan.blockId, { - ...blockState, - props: plan.props, - }); - - return { - ops: [{ - type: "update-block", - blockId: plan.blockId, - props: plan.props, - }], - issues: [], - reviewSafe: false, - }; -} - -function isInlineConvertiblePendingBlock( - block: PendingInlineBlock, -): boolean { - return ( - (block.children?.length ?? 0) === 0 && - block.database == null && - block.type !== "table" && - block.type !== "database" - ); -} - -function isInlineConvertibleTargetBlock( - block: NonNullable>, -): boolean { - return block.children.length === 0 && block.type !== "table" && block.type !== "database"; -} - -function resolveInlineAlignmentPlan( - targetBlocks: Array>>, - parsedBlocks: PendingInlineBlock[], -): InlineAlignmentResolution { - const costs = Array.from( - { length: targetBlocks.length + 1 }, - () => new Array(parsedBlocks.length + 1).fill(0), - ); - - for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { - costs[targetIndex]![parsedBlocks.length] = - estimateInlineDeleteCost(targetBlocks[targetIndex]!) + - costs[targetIndex + 1]![parsedBlocks.length]!; - } - for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { - costs[targetBlocks.length]![parsedIndex] = - estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + - costs[targetBlocks.length]![parsedIndex + 1]!; - } - - for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { - for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { - const substituteCost = - estimateInlineSubstituteCost( - targetBlocks[targetIndex]!, - parsedBlocks[parsedIndex]!, - ) + costs[targetIndex + 1]![parsedIndex + 1]!; - const deleteCost = - estimateInlineDeleteCost(targetBlocks[targetIndex]!) + - costs[targetIndex + 1]![parsedIndex]!; - const insertCost = - estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + - costs[targetIndex]![parsedIndex + 1]!; - costs[targetIndex]![parsedIndex] = Math.min( - substituteCost, - deleteCost, - insertCost, - ); - } - } - - const alignment: InlineAlignmentStep[] = []; - let targetIndex = 0; - let parsedIndex = 0; - while (targetIndex < targetBlocks.length && parsedIndex < parsedBlocks.length) { - const bestCost = costs[targetIndex]![parsedIndex]!; - const substituteCost = - estimateInlineSubstituteCost( - targetBlocks[targetIndex]!, - parsedBlocks[parsedIndex]!, - ) + costs[targetIndex + 1]![parsedIndex + 1]!; - const deleteCost = - estimateInlineDeleteCost(targetBlocks[targetIndex]!) + - costs[targetIndex + 1]![parsedIndex]!; - const insertCost = - estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + - costs[targetIndex]![parsedIndex + 1]!; - - if ( - substituteCost === bestCost && - shouldPreferInlineSubstitution( - targetBlocks[targetIndex]!, - parsedBlocks[parsedIndex]!, - substituteCost, - deleteCost, - insertCost, - ) - ) { - alignment.push({ - kind: "substitute", - targetIndex, - parsedIndex, - }); - targetIndex += 1; - parsedIndex += 1; - continue; - } - - if (deleteCost === bestCost && deleteCost <= insertCost) { - alignment.push({ - kind: "delete", - targetIndex, - }); - targetIndex += 1; - continue; - } - alignment.push({ - kind: "insert", - parsedIndex, - }); - parsedIndex += 1; - } - - while (targetIndex < targetBlocks.length) { - alignment.push({ - kind: "delete", - targetIndex, - }); - targetIndex += 1; - } - while (parsedIndex < parsedBlocks.length) { - alignment.push({ - kind: "insert", - parsedIndex, - }); - parsedIndex += 1; - } - - return { - steps: alignment, - metrics: summarizeInlineAlignment(alignment, targetBlocks, parsedBlocks, costs[0]?.[0] ?? 0), - }; -} - -function shouldPreferInlineSubstitution( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, - substituteCost: number, - deleteCost: number, - insertCost: number, -): boolean { - if (substituteCost < deleteCost && substituteCost < insertCost) { - return true; - } - if (substituteCost > deleteCost || substituteCost > insertCost) { - return false; - } - return areBlocksReusableMatch(targetBlock, parsedBlock); -} - -function estimateInlineSubstituteCost( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): number { - return estimateInlineBlockRewriteCost(targetBlock, parsedBlock); -} - -function estimateInlineDeleteCost( - _targetBlock: NonNullable>, -): number { - return 1; -} - -function estimateInlineInsertCost(block: PendingInlineBlock): number { - let cost = 1; - if ((block.content ?? "").length > 0) { - cost += 1; - } - for (const mark of block.marks ?? []) { - if (mark.end > mark.start) { - cost += 1; - } - } - return cost; -} - -function estimateInlineBlockRewriteCost( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): number { - let cost = 0; - if (parsedBlock.type !== targetBlock.type) { - cost += 1; - } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { - cost += 1; - } - - const nextText = parsedBlock.content ?? ""; - if (targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0) { - cost += 1; - } - for (const mark of parsedBlock.marks ?? []) { - if (mark.end > mark.start) { - cost += 1; - } - } - return cost; -} - -function summarizeInlineAlignment( - alignment: InlineAlignmentStep[], - targetBlocks: Array>>, - parsedBlocks: PendingInlineBlock[], - estimatedOperationCost: number, -): FlowPatchAlignmentMetrics { - let preservedBlockCount = 0; - let rewrittenBlockCount = 0; - let unchangedBlockCount = 0; - let insertedBlockCount = 0; - let deletedBlockCount = 0; - - for (const step of alignment) { - if (step.kind === "insert") { - insertedBlockCount += 1; - continue; - } - if (step.kind === "delete") { - deletedBlockCount += 1; - continue; - } - - preservedBlockCount += 1; - const rewriteCost = estimateInlineBlockRewriteCost( - targetBlocks[step.targetIndex!]!, - parsedBlocks[step.parsedIndex!]!, - ); - if (rewriteCost > 0) { - rewrittenBlockCount += 1; - } else { - unchangedBlockCount += 1; - } - } - - return { - preservedBlockCount, - rewrittenBlockCount, - unchangedBlockCount, - insertedBlockCount, - deletedBlockCount, - estimatedOperationCost, - }; -} - -function mergeFlowPatchAlignmentMetrics( - left: FlowPatchAlignmentMetrics | undefined, - right: FlowPatchAlignmentMetrics | undefined, -): FlowPatchAlignmentMetrics | undefined { - if (!left) { - return right; - } - if (!right) { - return left; - } - return { - preservedBlockCount: left.preservedBlockCount + right.preservedBlockCount, - rewrittenBlockCount: left.rewrittenBlockCount + right.rewrittenBlockCount, - unchangedBlockCount: left.unchangedBlockCount + right.unchangedBlockCount, - insertedBlockCount: left.insertedBlockCount + right.insertedBlockCount, - deletedBlockCount: left.deletedBlockCount + right.deletedBlockCount, - estimatedOperationCost: - left.estimatedOperationCost + right.estimatedOperationCost, - }; -} - -function areBlocksReusableMatch( - targetBlock: NonNullable>, - parsedBlock: PendingInlineBlock, -): boolean { - return ( - targetBlock.type === parsedBlock.type && - areRecordValuesEqual(targetBlock.props, parsedBlock.props) && - areTextsReusableMatch(targetBlock.textContent(), parsedBlock.content ?? "") - ); -} - -function areTextsReusableMatch(left: string, right: string): boolean { - const normalizedLeft = normalizeReusableText(left); - const normalizedRight = normalizeReusableText(right); - if (normalizedLeft === normalizedRight) { - return true; - } - if (normalizedLeft.length === 0 || normalizedRight.length === 0) { - return false; - } - if ( - normalizedLeft.includes(normalizedRight) || - normalizedRight.includes(normalizedLeft) - ) { - return true; - } - const sharedBoundaryLength = - resolveSharedPrefixLength(normalizedLeft, normalizedRight) + - resolveSharedSuffixLength(normalizedLeft, normalizedRight); - const minLength = Math.min(normalizedLeft.length, normalizedRight.length); - if (sharedBoundaryLength < Math.ceil(minLength * 0.5)) { - return false; - } - const maxLength = Math.max(normalizedLeft.length, normalizedRight.length); - const maxDistance = Math.max(4, Math.floor(maxLength * 0.4)); - return resolveLevenshteinDistance(normalizedLeft, normalizedRight, maxDistance) <= maxDistance; -} - -function normalizeReusableText(text: string): string { - return text.trim().replace(/\s+/g, " ").toLowerCase(); -} - -function resolveSharedPrefixLength(left: string, right: string): number { - let index = 0; - while (index < left.length && index < right.length && left[index] === right[index]) { - index += 1; - } - return index; -} - -function resolveSharedSuffixLength(left: string, right: string): number { - let count = 0; - while ( - count < left.length && - count < right.length && - left[left.length - 1 - count] === right[right.length - 1 - count] - ) { - count += 1; - } - return count; -} - -function resolveLevenshteinDistance( - left: string, - right: string, - maxDistance: number, -): number { - const previous = Array.from({ length: right.length + 1 }, (_, index) => index); - const current = new Array(right.length + 1); - - for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { - current[0] = leftIndex; - let rowMin = current[0]!; - for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { - const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; - current[rightIndex] = Math.min( - current[rightIndex - 1]! + 1, - previous[rightIndex]! + 1, - previous[rightIndex - 1]! + substitutionCost, - ); - rowMin = Math.min(rowMin, current[rightIndex]!); - } - if (rowMin > maxDistance) { - return maxDistance + 1; - } - for (let index = 0; index <= right.length; index += 1) { - previous[index] = current[index]!; - } - } - - return previous[right.length]!; -} - -function buildInlinePendingBlockInsertOps( - blocks: PendingInlineBlock[], - position: { before: string } | { after: string } | "last", -): DocumentOp[] { - const ops: DocumentOp[] = []; - let currentPosition = position; - for (const block of blocks) { - const blockId = generateId(); - ops.push({ - type: "insert-block", - blockId, - blockType: block.type, - props: block.props, - position: currentPosition, - }); - if ((block.content ?? "").length > 0) { - ops.push({ - type: "insert-text", - blockId, - offset: 0, - text: block.content!, - }); - } - for (const mark of block.marks ?? []) { - if (mark.end <= mark.start) { - continue; - } - ops.push({ - type: "format-text", - blockId, - offset: mark.start, - length: mark.end - mark.start, - marks: { [mark.type]: mark.props ?? true }, - }); - } - currentPosition = { after: blockId }; - } - return ops; -} - -function resolveLastInsertedBlockId(ops: DocumentOp[]): string | null { - for (let index = ops.length - 1; index >= 0; index -= 1) { - const op = ops[index]!; - if (op.type === "insert-block") { - return op.blockId; - } - } - return null; -} - -function resolveInsertionPosition( - blockBefore: string | null, - blockAfter: string | null, -): { before: string } | { after: string } | "last" { - if (blockBefore) { - return { after: blockBefore }; - } - if (blockAfter) { - return { before: blockAfter }; - } - return "last"; -} - -function areRecordValuesEqual( - left: Record, - right: Record, -): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - - return leftEntries.every(([key, value]) => { - if (!(key in right)) { - return false; - } - return JSON.stringify(value) === JSON.stringify(right[key]); - }); -} - -function buildBlockMoveExecution( - editor: Editor, - plan: BlockMovePlan, - context: PlanExecutionContext, -): PlanExecutionResult { - if (!resolveBlockState(editor, context, plan.blockId)) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - - return { - ops: [{ - type: "move-block", - blockId: plan.blockId, - position: plan.position, - }], - issues: [], - reviewSafe: true, - }; -} - -function buildBlockConvertExecution( - editor: Editor, - plan: BlockConvertPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const blockState = resolveBlockState(editor, context, plan.blockId); - if (!blockState) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - context.virtualBlocks.set( - plan.blockId, - createVirtualBlockState( - plan.newType, - plan.props ?? blockState.props, - blockState.textLength, - ), - ); - - return { - ops: [{ - type: "convert-block", - blockId: plan.blockId, - newType: plan.newType, - newProps: plan.props, - }], - issues: [], - reviewSafe: true, - }; -} - -function buildDatabaseEditExecution( - editor: Editor, - plan: DatabaseEditPlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const block = editor.getBlock(plan.blockId); - const virtualBlock = context.virtualBlocks.get(plan.blockId) ?? null; - const effectiveBlockType = virtualBlock?.type ?? block?.type ?? null; - if (!effectiveBlockType) { - return withIssue( - `${plan.kind}.blockId`, - "missing-block", - `Block "${plan.blockId}" was not found.`, - ); - } - if (effectiveBlockType !== "database") { - return withIssue( - `${plan.kind}.blockId`, - "unsupported-target", - `Block "${plan.blockId}" is not a database block.`, - ); - } - - const ops: DocumentOp[] = []; - const knownColumnIds = new Set([ - ...(block?.type === "database" - ? block.tableColumns().map((column) => column.id) - : []), - ...(virtualBlock?.database?.columnIds ?? []), - ]); - const knownRowIds = new Set([ - ...(block?.type === "database" ? readDatabaseRowIds(block) : []), - ...(virtualBlock?.database?.rowIds ?? []), - ]); - const knownViewIds = new Set([ - ...(block?.type === "database" - ? block.databaseViews().map((view) => view.id) - : []), - ...(virtualBlock?.database?.viewIds ?? []), - ]); - - for (const step of plan.steps) { - switch (step.op) { - case "add_column": - ops.push({ - type: "database-add-column", - blockId: plan.blockId, - column: step.column, - }); - knownColumnIds.add(step.column.id); - break; - case "update_column": - ops.push({ - type: "database-update-column", - blockId: plan.blockId, - columnId: step.columnId, - patch: step.patch, - }); - break; - case "insert_row": { - const rowId = step.rowId ?? generateId(); - ops.push({ - type: "database-insert-row", - blockId: plan.blockId, - rowId, - values: stringifyRecord(step.values), - }); - knownRowIds.add(rowId); - break; - } - case "update_cell": - ops.push({ - type: "database-update-cell", - blockId: plan.blockId, - rowId: step.rowId, - columnId: step.columnId, - value: stringifyDatabaseValue(step.value), - }); - break; - case "add_view": - ops.push({ - type: "database-add-view", - blockId: plan.blockId, - view: step.view, - }); - knownViewIds.add(step.view.id); - break; - case "set_active_view": - ops.push({ - type: "database-set-active-view", - blockId: plan.blockId, - viewId: step.viewId, - }); - break; - } - } - - if (virtualBlock?.database) { - virtualBlock.database.columnIds = knownColumnIds; - virtualBlock.database.rowIds = knownRowIds; - virtualBlock.database.viewIds = knownViewIds; - } - - return { - ops, - issues: [], - reviewSafe: false, - }; -} - -function buildReviewBundleExecution( - editor: Editor, - plan: ReviewBundlePlan, - context: PlanExecutionContext, -): PlanExecutionResult { - const ops: DocumentOp[] = []; - const issues: PlanExecutionIssue[] = []; - let reviewSafe = true; - - for (let index = 0; index < plan.plans.length; index += 1) { - const nestedPlan = plan.plans[index]!; - const execution = buildPlanExecution(editor, nestedPlan, context); - ops.push(...execution.ops); - issues.push( - ...execution.issues.map((issue) => ({ - ...issue, - path: `${plan.kind}.plans[${index}].${issue.path}`, - })), - ); - reviewSafe &&= execution.reviewSafe; - } - - return { - ops, - issues, - reviewSafe, - }; -} - -function createVirtualBlockState( - blockType: string, - props: Record = {}, - text: string | number = 0, -): VirtualBlockState { - const textLength = typeof text === "number" ? text : text.length; - if (blockType === "database") { - return { - type: blockType, - props, - textLength, - database: { - columnIds: new Set(), - rowIds: new Set(), - viewIds: new Set(), - }, - }; - } - return { - type: blockType, - props, - textLength, - }; -} - -function resolveBlockState( - editor: Editor, - context: PlanExecutionContext, - blockId: string, -): VirtualBlockState | null { - const virtualBlock = context.virtualBlocks.get(blockId) ?? null; - if (virtualBlock) { - return virtualBlock; - } - - const block = editor.getBlock(blockId); - if (!block) { - return null; - } - - const nextState = createVirtualBlockState( - block.type, - { ...block.props }, - block.length(), - ); - if (block.type === "database") { - nextState.database = { - columnIds: new Set(block.tableColumns().map((column) => column.id)), - rowIds: new Set(readDatabaseRowIds(block)), - viewIds: new Set(block.databaseViews().map((view) => view.id)), - }; - } - return nextState; -} - -function withIssue( - path: string, - code: PlanExecutionIssue["code"], - message: string, -): PlanExecutionResult { - return { - ops: [], - issues: [{ path, code, message }], - reviewSafe: false, - }; -} - -function stringifyRecord( - value: Record, -): Record { - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [ - key, - stringifyDatabaseValue(entryValue), - ]), - ); -} - -function readDatabaseRowIds( - block: ReturnType, -): string[] { - if (!block) { - return []; - } - const rowIds: string[] = []; - for (let index = 0; index < block.tableRowCount(); index += 1) { - const rowId = block.tableRow(index)?.id; - if (rowId) { - rowIds.push(rowId); - } - } - return rowIds; -} - -function stringifyDatabaseValue(value: unknown): string { - if (value == null) { - return ""; - } - if (typeof value === "string") { - return value; - } - if ( - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} +export { buildDocumentMutationPlanExecution } from "./planExecutorParts/planExecutorPart1"; +export type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics } from "./planExecutorParts/planExecutorPart1"; diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts new file mode 100644 index 0000000..a79933a --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart1.ts @@ -0,0 +1,290 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export interface PlanExecutionIssue { + path: string; + code: + | "missing-block" + | "invalid-target" + | "unsupported-target" + | "invalid-range"; + message: string; +} + +export interface PlanExecutionResult { + ops: DocumentOp[]; + issues: PlanExecutionIssue[]; + reviewSafe: boolean; + metrics?: PlanExecutionMetrics; +} + +export interface PlanExecutionMetrics { + flowPatchAlignment?: FlowPatchAlignmentMetrics; +} + +export interface FlowPatchAlignmentMetrics { + preservedBlockCount: number; + rewrittenBlockCount: number; + unchangedBlockCount: number; + insertedBlockCount: number; + deletedBlockCount: number; + estimatedOperationCost: number; +} + +export interface VirtualBlockState { + type: string; + props: Record; + textLength: number; + database?: { + columnIds: Set; + rowIds: Set; + viewIds: Set; + }; +} + +export interface PlanExecutionContext { + virtualBlocks: Map; +} + +export interface PendingInlineMark { + type: string; + props?: Record; + start: number; + end: number; +} + +export interface PendingInlineBlock { + type: string; + props: Record; + content?: string; + marks?: PendingInlineMark[]; + children?: unknown[]; + database?: unknown; +} + +export interface InlineAlignmentStep { + kind: "substitute" | "insert" | "delete"; + targetIndex?: number; + parsedIndex?: number; +} + +export interface InlineAlignmentResolution { + steps: InlineAlignmentStep[]; + metrics: FlowPatchAlignmentMetrics; +} + +export function buildDocumentMutationPlanExecution( + editor: Editor, + plan: DocumentMutationPlan, +): PlanExecutionResult { + const context: PlanExecutionContext = { + virtualBlocks: new Map(), + }; + return buildPlanExecution(editor, plan, context); +} + +export function buildPlanExecution( + editor: Editor, + plan: DocumentMutationPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + switch (plan.kind) { + case "text_edit": + return buildTextEditExecution(editor, plan, context); + case "flow_patch": + return buildFlowPatchExecution(editor, plan); + case "block_insert": + return buildBlockInsertExecution(editor, plan, context); + case "block_update": + return buildBlockUpdateExecution(editor, plan, context); + case "block_move": + return buildBlockMoveExecution(editor, plan, context); + case "block_convert": + return buildBlockConvertExecution(editor, plan, context); + case "database_edit": + return buildDatabaseEditExecution(editor, plan, context); + case "review_bundle": + return buildReviewBundleExecution(editor, plan, context); + } +} + +export function buildTextEditExecution( + editor: Editor, + plan: TextEditPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockState = resolveBlockState(editor, context, plan.target.blockId); + if (!blockState) { + return withIssue( + `${plan.kind}.target.blockId`, + "missing-block", + `Block "${plan.target.blockId}" was not found.`, + ); + } + + const blockLength = blockState.textLength; + if ( + plan.target.range && + (plan.target.range.startOffset < 0 || + plan.target.range.endOffset < plan.target.range.startOffset || + plan.target.range.endOffset > blockLength) + ) { + return withIssue( + `${plan.kind}.target.range`, + "invalid-range", + "Text edit range is outside the target block.", + ); + } + + if (plan.operation === "append") { + context.virtualBlocks.set(plan.target.blockId, { + ...blockState, + textLength: blockLength + plan.text.length, + }); + return { + ops: [{ + type: "insert-text", + blockId: plan.target.blockId, + offset: blockLength, + text: plan.text, + }], + issues: [], + reviewSafe: true, + }; + } + + if (plan.operation === "insert") { + const offset = plan.target.range?.startOffset ?? blockLength; + context.virtualBlocks.set(plan.target.blockId, { + ...blockState, + textLength: blockLength + plan.text.length, + }); + return { + ops: [{ + type: "insert-text", + blockId: plan.target.blockId, + offset, + text: plan.text, + }], + issues: [], + reviewSafe: true, + }; + } + + const offset = plan.target.range?.startOffset ?? 0; + const length = + plan.target.range != null + ? plan.target.range.endOffset - plan.target.range.startOffset + : blockLength; + context.virtualBlocks.set(plan.target.blockId, { + ...blockState, + textLength: blockLength - length + plan.text.length, + }); + + return { + ops: [{ + type: "replace-text", + blockId: plan.target.blockId, + offset, + length, + text: plan.text, + }], + issues: [], + reviewSafe: true, + }; +} + +export function buildFlowPatchExecution( + editor: Editor, + plan: FlowPatchPlan, +): PlanExecutionResult { + const ops: DocumentOp[] = []; + const issues: PlanExecutionIssue[] = []; + let reviewSafe = true; + let flowPatchAlignmentMetrics: FlowPatchAlignmentMetrics | undefined; + + for (const [index, edit] of plan.edits.entries()) { + const execution = buildFlowPatchEditExecution(editor, edit, `${plan.kind}.edits[${index}]`); + ops.push(...execution.ops); + issues.push(...execution.issues); + reviewSafe = reviewSafe && execution.reviewSafe; + flowPatchAlignmentMetrics = mergeFlowPatchAlignmentMetrics( + flowPatchAlignmentMetrics, + execution.metrics?.flowPatchAlignment, + ); + } + + return { + ops, + issues, + reviewSafe, + metrics: + flowPatchAlignmentMetrics == null + ? undefined + : { flowPatchAlignment: flowPatchAlignmentMetrics }, + }; +} + +export function buildBlockInsertExecution( + editor: Editor, + plan: BlockInsertPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockId = plan.blockId ?? generateId(); + if (resolveBlockState(editor, context, blockId)) { + return withIssue( + `${plan.kind}.blockId`, + "invalid-target", + `Block "${blockId}" already exists.`, + ); + } + + context.virtualBlocks.set( + blockId, + createVirtualBlockState( + plan.blockType, + plan.props ?? {}, + plan.initialText ?? "", + ), + ); + const ops: DocumentOp[] = [{ + type: "insert-block", + blockId, + blockType: plan.blockType, + props: plan.props ?? {}, + position: plan.position, + }]; + + if (plan.initialText && plan.initialText.length > 0) { + ops.push({ + type: "insert-text", + blockId, + offset: 0, + text: plan.initialText, + }); + } + + return { + ops, + issues: [], + reviewSafe: true, + }; +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts new file mode 100644 index 0000000..cd55e71 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart2.ts @@ -0,0 +1,367 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export function buildFlowPatchEditExecution( + editor: Editor, + edit: FlowPatchEdit, + path: string, +): PlanExecutionResult { + const targetBlockIds = + edit.locator.blockIds?.filter((blockId) => blockId.length > 0) ?? + (edit.locator.blockId ? [edit.locator.blockId] : []); + const primaryBlockId = targetBlockIds[0] ?? null; + const primaryBlock = primaryBlockId ? editor.getBlock(primaryBlockId) : null; + + if ( + edit.locator.expectedBlockType && + primaryBlock && + primaryBlock.type !== edit.locator.expectedBlockType + ) { + return withIssue( + `${path}.locator.expectedBlockType`, + "unsupported-target", + `Block "${primaryBlock.id}" is "${primaryBlock.type}", expected "${edit.locator.expectedBlockType}".`, + ); + } + + switch (edit.operation) { + case "replace_text": { + if (!primaryBlockId || !primaryBlock) { + return withIssue( + `${path}.locator.blockId`, + "missing-block", + "Flow patch replace_text requires an existing target block.", + ); + } + return { + ops: [{ + type: "replace-text", + blockId: primaryBlockId, + offset: 0, + length: primaryBlock.length(), + text: edit.text ?? "", + }], + issues: [], + reviewSafe: true, + }; + } + case "append_text": { + if (!primaryBlockId || !primaryBlock) { + return withIssue( + `${path}.locator.blockId`, + "missing-block", + "Flow patch append_text requires an existing target block.", + ); + } + return { + ops: [{ + type: "insert-text", + blockId: primaryBlockId, + offset: primaryBlock.length(), + text: edit.text ?? "", + }], + issues: [], + reviewSafe: true, + }; + } + case "insert_before": + case "insert_after": { + if (!primaryBlockId || !primaryBlock) { + return withIssue( + `${path}.locator.blockId`, + "missing-block", + `Flow patch ${edit.operation} requires an existing target block.`, + ); + } + const { ops } = buildDocumentWriteOps(editor, { + format: "markdown", + content: edit.markdown ?? "", + position: + edit.operation === "insert_before" + ? { before: primaryBlockId } + : { after: primaryBlockId }, + surface: "ai-flow-patch", + }); + return { + ops, + issues: [], + reviewSafe: true, + }; + } + case "replace_blocks": { + if (targetBlockIds.length === 0) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch replace_blocks requires one or more target blocks.", + ); + } + if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch replace_blocks targets a missing block.", + ); + } + const optimized = buildOptimizedBlockReplacement( + editor, + targetBlockIds, + edit.markdown ?? "", + ); + if (optimized) { + return optimized; + } + const { ops } = buildDocumentWriteOps(editor, { + format: "markdown", + content: edit.markdown ?? "", + position: { before: targetBlockIds[0]! }, + surface: "ai-flow-patch", + }); + return { + ops: [ + ...ops, + ...targetBlockIds.map((blockId) => ({ + type: "delete-block", + blockId, + }) satisfies DocumentOp), + ], + issues: [], + reviewSafe: true, + }; + } + case "delete_blocks": { + if (targetBlockIds.length === 0) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch delete_blocks requires one or more target blocks.", + ); + } + if (targetBlockIds.some((blockId) => !editor.getBlock(blockId))) { + return withIssue( + `${path}.locator.blockIds`, + "missing-block", + "Flow patch delete_blocks targets a missing block.", + ); + } + return { + ops: targetBlockIds.map((blockId) => ({ + type: "delete-block", + blockId, + }) satisfies DocumentOp), + issues: [], + reviewSafe: true, + }; + } + } +} + +export function buildOptimizedBlockReplacement( + editor: Editor, + targetBlockIds: string[], + markdown: string, +): PlanExecutionResult | null { + if (targetBlockIds.length === 0) { + return null; + } + + const targetBlocks = targetBlockIds + .map((blockId) => editor.getBlock(blockId)) + .filter((block): block is NonNullable => block != null); + if (targetBlocks.length !== targetBlockIds.length) { + return null; + } + + const parsedBlocks = buildDocumentWriteOps(editor, { + format: "markdown", + content: markdown, + surface: "ai-flow-patch-optimize", + }).blocks as PendingInlineBlock[]; + if ( + parsedBlocks.some((parsedBlock) => !isInlineConvertiblePendingBlock(parsedBlock)) + ) { + return null; + } + if (targetBlocks.some((block) => !isInlineConvertibleTargetBlock(block))) { + return null; + } + + const alignment = resolveInlineAlignmentPlan(targetBlocks, parsedBlocks); + const ops = buildInlineAlignmentOps(alignment.steps, targetBlocks, parsedBlocks); + + return { + ops, + issues: [], + reviewSafe: true, + metrics: { + flowPatchAlignment: alignment.metrics, + }, + }; +} + +export function buildInlineBlockRewriteOps( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): DocumentOp[] { + const ops: DocumentOp[] = []; + if (parsedBlock.type !== targetBlock.type) { + ops.push({ + type: "convert-block", + blockId: targetBlock.id, + newType: parsedBlock.type, + newProps: parsedBlock.props, + }); + } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { + ops.push({ + type: "update-block", + blockId: targetBlock.id, + props: parsedBlock.props, + }); + } + + const nextText = parsedBlock.content ?? ""; + const needsTextRewrite = + targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0; + if (needsTextRewrite) { + ops.push({ + type: "replace-text", + blockId: targetBlock.id, + offset: 0, + length: targetBlock.length(), + text: nextText, + }); + for (const mark of parsedBlock.marks ?? []) { + if (mark.end <= mark.start) { + continue; + } + ops.push({ + type: "format-text", + blockId: targetBlock.id, + offset: mark.start, + length: mark.end - mark.start, + marks: { [mark.type]: mark.props ?? true }, + }); + } + } + + return ops; +} + +export function buildInlineAlignmentOps( + alignment: InlineAlignmentStep[], + targetBlocks: Array>>, + parsedBlocks: PendingInlineBlock[], +): DocumentOp[] { + const ops: DocumentOp[] = []; + const pendingInserts: PendingInlineBlock[] = []; + let blockBefore: string | null = null; + + for (const step of alignment) { + if (step.kind === "insert") { + pendingInserts.push(parsedBlocks[step.parsedIndex!]!); + continue; + } + + if (step.kind === "substitute") { + const targetBlock = targetBlocks[step.targetIndex!]!; + if (pendingInserts.length > 0) { + const insertOps = buildInlinePendingBlockInsertOps( + pendingInserts, + resolveInsertionPosition(blockBefore, targetBlock.id), + ); + ops.push(...insertOps); + blockBefore = resolveLastInsertedBlockId(insertOps) ?? blockBefore; + pendingInserts.length = 0; + } + ops.push( + ...buildInlineBlockRewriteOps( + targetBlock, + parsedBlocks[step.parsedIndex!]!, + ), + ); + blockBefore = targetBlock.id; + continue; + } + + ops.push({ + type: "delete-block", + blockId: targetBlocks[step.targetIndex!]!.id, + }); + } + + if (pendingInserts.length > 0) { + ops.push( + ...buildInlinePendingBlockInsertOps( + pendingInserts, + resolveInsertionPosition(blockBefore, null), + ), + ); + } + + return ops; +} + +export function buildBlockUpdateExecution( + editor: Editor, + plan: BlockUpdatePlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockState = resolveBlockState(editor, context, plan.blockId); + if (!blockState) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + context.virtualBlocks.set(plan.blockId, { + ...blockState, + props: plan.props, + }); + + return { + ops: [{ + type: "update-block", + blockId: plan.blockId, + props: plan.props, + }], + issues: [], + reviewSafe: false, + }; +} + +export function isInlineConvertiblePendingBlock( + block: PendingInlineBlock, +): boolean { + return ( + (block.children?.length ?? 0) === 0 && + block.database == null && + block.type !== "table" && + block.type !== "database" + ); +} + +export function isInlineConvertibleTargetBlock( + block: NonNullable>, +): boolean { + return block.children.length === 0 && block.type !== "table" && block.type !== "database"; +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts new file mode 100644 index 0000000..3f51323 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart3.ts @@ -0,0 +1,358 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export function resolveInlineAlignmentPlan( + targetBlocks: Array>>, + parsedBlocks: PendingInlineBlock[], +): InlineAlignmentResolution { + const costs = Array.from( + { length: targetBlocks.length + 1 }, + () => new Array(parsedBlocks.length + 1).fill(0), + ); + + for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { + costs[targetIndex]![parsedBlocks.length] = + estimateInlineDeleteCost(targetBlocks[targetIndex]!) + + costs[targetIndex + 1]![parsedBlocks.length]!; + } + for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { + costs[targetBlocks.length]![parsedIndex] = + estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + + costs[targetBlocks.length]![parsedIndex + 1]!; + } + + for (let targetIndex = targetBlocks.length - 1; targetIndex >= 0; targetIndex -= 1) { + for (let parsedIndex = parsedBlocks.length - 1; parsedIndex >= 0; parsedIndex -= 1) { + const substituteCost = + estimateInlineSubstituteCost( + targetBlocks[targetIndex]!, + parsedBlocks[parsedIndex]!, + ) + costs[targetIndex + 1]![parsedIndex + 1]!; + const deleteCost = + estimateInlineDeleteCost(targetBlocks[targetIndex]!) + + costs[targetIndex + 1]![parsedIndex]!; + const insertCost = + estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + + costs[targetIndex]![parsedIndex + 1]!; + costs[targetIndex]![parsedIndex] = Math.min( + substituteCost, + deleteCost, + insertCost, + ); + } + } + + const alignment: InlineAlignmentStep[] = []; + let targetIndex = 0; + let parsedIndex = 0; + while (targetIndex < targetBlocks.length && parsedIndex < parsedBlocks.length) { + const bestCost = costs[targetIndex]![parsedIndex]!; + const substituteCost = + estimateInlineSubstituteCost( + targetBlocks[targetIndex]!, + parsedBlocks[parsedIndex]!, + ) + costs[targetIndex + 1]![parsedIndex + 1]!; + const deleteCost = + estimateInlineDeleteCost(targetBlocks[targetIndex]!) + + costs[targetIndex + 1]![parsedIndex]!; + const insertCost = + estimateInlineInsertCost(parsedBlocks[parsedIndex]!) + + costs[targetIndex]![parsedIndex + 1]!; + + if ( + substituteCost === bestCost && + shouldPreferInlineSubstitution( + targetBlocks[targetIndex]!, + parsedBlocks[parsedIndex]!, + substituteCost, + deleteCost, + insertCost, + ) + ) { + alignment.push({ + kind: "substitute", + targetIndex, + parsedIndex, + }); + targetIndex += 1; + parsedIndex += 1; + continue; + } + + if (deleteCost === bestCost && deleteCost <= insertCost) { + alignment.push({ + kind: "delete", + targetIndex, + }); + targetIndex += 1; + continue; + } + alignment.push({ + kind: "insert", + parsedIndex, + }); + parsedIndex += 1; + } + + while (targetIndex < targetBlocks.length) { + alignment.push({ + kind: "delete", + targetIndex, + }); + targetIndex += 1; + } + while (parsedIndex < parsedBlocks.length) { + alignment.push({ + kind: "insert", + parsedIndex, + }); + parsedIndex += 1; + } + + return { + steps: alignment, + metrics: summarizeInlineAlignment(alignment, targetBlocks, parsedBlocks, costs[0]?.[0] ?? 0), + }; +} + +export function shouldPreferInlineSubstitution( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, + substituteCost: number, + deleteCost: number, + insertCost: number, +): boolean { + if (substituteCost < deleteCost && substituteCost < insertCost) { + return true; + } + if (substituteCost > deleteCost || substituteCost > insertCost) { + return false; + } + return areBlocksReusableMatch(targetBlock, parsedBlock); +} + +export function estimateInlineSubstituteCost( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): number { + return estimateInlineBlockRewriteCost(targetBlock, parsedBlock); +} + +export function estimateInlineDeleteCost( + _targetBlock: NonNullable>, +): number { + return 1; +} + +export function estimateInlineInsertCost(block: PendingInlineBlock): number { + let cost = 1; + if ((block.content ?? "").length > 0) { + cost += 1; + } + for (const mark of block.marks ?? []) { + if (mark.end > mark.start) { + cost += 1; + } + } + return cost; +} + +export function estimateInlineBlockRewriteCost( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): number { + let cost = 0; + if (parsedBlock.type !== targetBlock.type) { + cost += 1; + } else if (!areRecordValuesEqual(targetBlock.props, parsedBlock.props)) { + cost += 1; + } + + const nextText = parsedBlock.content ?? ""; + if (targetBlock.textContent() !== nextText || (parsedBlock.marks?.length ?? 0) > 0) { + cost += 1; + } + for (const mark of parsedBlock.marks ?? []) { + if (mark.end > mark.start) { + cost += 1; + } + } + return cost; +} + +export function summarizeInlineAlignment( + alignment: InlineAlignmentStep[], + targetBlocks: Array>>, + parsedBlocks: PendingInlineBlock[], + estimatedOperationCost: number, +): FlowPatchAlignmentMetrics { + let preservedBlockCount = 0; + let rewrittenBlockCount = 0; + let unchangedBlockCount = 0; + let insertedBlockCount = 0; + let deletedBlockCount = 0; + + for (const step of alignment) { + if (step.kind === "insert") { + insertedBlockCount += 1; + continue; + } + if (step.kind === "delete") { + deletedBlockCount += 1; + continue; + } + + preservedBlockCount += 1; + const rewriteCost = estimateInlineBlockRewriteCost( + targetBlocks[step.targetIndex!]!, + parsedBlocks[step.parsedIndex!]!, + ); + if (rewriteCost > 0) { + rewrittenBlockCount += 1; + } else { + unchangedBlockCount += 1; + } + } + + return { + preservedBlockCount, + rewrittenBlockCount, + unchangedBlockCount, + insertedBlockCount, + deletedBlockCount, + estimatedOperationCost, + }; +} + +export function mergeFlowPatchAlignmentMetrics( + left: FlowPatchAlignmentMetrics | undefined, + right: FlowPatchAlignmentMetrics | undefined, +): FlowPatchAlignmentMetrics | undefined { + if (!left) { + return right; + } + if (!right) { + return left; + } + return { + preservedBlockCount: left.preservedBlockCount + right.preservedBlockCount, + rewrittenBlockCount: left.rewrittenBlockCount + right.rewrittenBlockCount, + unchangedBlockCount: left.unchangedBlockCount + right.unchangedBlockCount, + insertedBlockCount: left.insertedBlockCount + right.insertedBlockCount, + deletedBlockCount: left.deletedBlockCount + right.deletedBlockCount, + estimatedOperationCost: + left.estimatedOperationCost + right.estimatedOperationCost, + }; +} + +export function areBlocksReusableMatch( + targetBlock: NonNullable>, + parsedBlock: PendingInlineBlock, +): boolean { + return ( + targetBlock.type === parsedBlock.type && + areRecordValuesEqual(targetBlock.props, parsedBlock.props) && + areTextsReusableMatch(targetBlock.textContent(), parsedBlock.content ?? "") + ); +} + +export function areTextsReusableMatch(left: string, right: string): boolean { + const normalizedLeft = normalizeReusableText(left); + const normalizedRight = normalizeReusableText(right); + if (normalizedLeft === normalizedRight) { + return true; + } + if (normalizedLeft.length === 0 || normalizedRight.length === 0) { + return false; + } + if ( + normalizedLeft.includes(normalizedRight) || + normalizedRight.includes(normalizedLeft) + ) { + return true; + } + const sharedBoundaryLength = + resolveSharedPrefixLength(normalizedLeft, normalizedRight) + + resolveSharedSuffixLength(normalizedLeft, normalizedRight); + const minLength = Math.min(normalizedLeft.length, normalizedRight.length); + if (sharedBoundaryLength < Math.ceil(minLength * 0.5)) { + return false; + } + const maxLength = Math.max(normalizedLeft.length, normalizedRight.length); + const maxDistance = Math.max(4, Math.floor(maxLength * 0.4)); + return resolveLevenshteinDistance(normalizedLeft, normalizedRight, maxDistance) <= maxDistance; +} + +export function normalizeReusableText(text: string): string { + return text.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export function resolveSharedPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +export function resolveSharedSuffixLength(left: string, right: string): number { + let count = 0; + while ( + count < left.length && + count < right.length && + left[left.length - 1 - count] === right[right.length - 1 - count] + ) { + count += 1; + } + return count; +} + +export function resolveLevenshteinDistance( + left: string, + right: string, + maxDistance: number, +): number { + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + const current = new Array(right.length + 1); + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + current[0] = leftIndex; + let rowMin = current[0]!; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + current[rightIndex] = Math.min( + current[rightIndex - 1]! + 1, + previous[rightIndex]! + 1, + previous[rightIndex - 1]! + substitutionCost, + ); + rowMin = Math.min(rowMin, current[rightIndex]!); + } + if (rowMin > maxDistance) { + return maxDistance + 1; + } + for (let index = 0; index <= right.length; index += 1) { + previous[index] = current[index]!; + } + } + + return previous[right.length]!; +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts new file mode 100644 index 0000000..37ac426 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart4.ts @@ -0,0 +1,377 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { readDatabaseRowIds, stringifyDatabaseValue } from "./planExecutorPart5"; + +export function buildInlinePendingBlockInsertOps( + blocks: PendingInlineBlock[], + position: { before: string } | { after: string } | "last", +): DocumentOp[] { + const ops: DocumentOp[] = []; + let currentPosition = position; + for (const block of blocks) { + const blockId = generateId(); + ops.push({ + type: "insert-block", + blockId, + blockType: block.type, + props: block.props, + position: currentPosition, + }); + if ((block.content ?? "").length > 0) { + ops.push({ + type: "insert-text", + blockId, + offset: 0, + text: block.content!, + }); + } + for (const mark of block.marks ?? []) { + if (mark.end <= mark.start) { + continue; + } + ops.push({ + type: "format-text", + blockId, + offset: mark.start, + length: mark.end - mark.start, + marks: { [mark.type]: mark.props ?? true }, + }); + } + currentPosition = { after: blockId }; + } + return ops; +} + +export function resolveLastInsertedBlockId(ops: DocumentOp[]): string | null { + for (let index = ops.length - 1; index >= 0; index -= 1) { + const op = ops[index]!; + if (op.type === "insert-block") { + return op.blockId; + } + } + return null; +} + +export function resolveInsertionPosition( + blockBefore: string | null, + blockAfter: string | null, +): { before: string } | { after: string } | "last" { + if (blockBefore) { + return { after: blockBefore }; + } + if (blockAfter) { + return { before: blockAfter }; + } + return "last"; +} + +export function areRecordValuesEqual( + left: Record, + right: Record, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + return leftEntries.every(([key, value]) => { + if (!(key in right)) { + return false; + } + return JSON.stringify(value) === JSON.stringify(right[key]); + }); +} + +export function buildBlockMoveExecution( + editor: Editor, + plan: BlockMovePlan, + context: PlanExecutionContext, +): PlanExecutionResult { + if (!resolveBlockState(editor, context, plan.blockId)) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + + return { + ops: [{ + type: "move-block", + blockId: plan.blockId, + position: plan.position, + }], + issues: [], + reviewSafe: true, + }; +} + +export function buildBlockConvertExecution( + editor: Editor, + plan: BlockConvertPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const blockState = resolveBlockState(editor, context, plan.blockId); + if (!blockState) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + context.virtualBlocks.set( + plan.blockId, + createVirtualBlockState( + plan.newType, + plan.props ?? blockState.props, + blockState.textLength, + ), + ); + + return { + ops: [{ + type: "convert-block", + blockId: plan.blockId, + newType: plan.newType, + newProps: plan.props, + }], + issues: [], + reviewSafe: true, + }; +} + +export function buildDatabaseEditExecution( + editor: Editor, + plan: DatabaseEditPlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const block = editor.getBlock(plan.blockId); + const virtualBlock = context.virtualBlocks.get(plan.blockId) ?? null; + const effectiveBlockType = virtualBlock?.type ?? block?.type ?? null; + if (!effectiveBlockType) { + return withIssue( + `${plan.kind}.blockId`, + "missing-block", + `Block "${plan.blockId}" was not found.`, + ); + } + if (effectiveBlockType !== "database") { + return withIssue( + `${plan.kind}.blockId`, + "unsupported-target", + `Block "${plan.blockId}" is not a database block.`, + ); + } + + const ops: DocumentOp[] = []; + const knownColumnIds = new Set([ + ...(block?.type === "database" + ? block.tableColumns().map((column) => column.id) + : []), + ...(virtualBlock?.database?.columnIds ?? []), + ]); + const knownRowIds = new Set([ + ...(block?.type === "database" ? readDatabaseRowIds(block) : []), + ...(virtualBlock?.database?.rowIds ?? []), + ]); + const knownViewIds = new Set([ + ...(block?.type === "database" + ? block.databaseViews().map((view) => view.id) + : []), + ...(virtualBlock?.database?.viewIds ?? []), + ]); + + for (const step of plan.steps) { + switch (step.op) { + case "add_column": + ops.push({ + type: "database-add-column", + blockId: plan.blockId, + column: step.column, + }); + knownColumnIds.add(step.column.id); + break; + case "update_column": + ops.push({ + type: "database-update-column", + blockId: plan.blockId, + columnId: step.columnId, + patch: step.patch, + }); + break; + case "insert_row": { + const rowId = step.rowId ?? generateId(); + ops.push({ + type: "database-insert-row", + blockId: plan.blockId, + rowId, + values: stringifyRecord(step.values), + }); + knownRowIds.add(rowId); + break; + } + case "update_cell": + ops.push({ + type: "database-update-cell", + blockId: plan.blockId, + rowId: step.rowId, + columnId: step.columnId, + value: stringifyDatabaseValue(step.value), + }); + break; + case "add_view": + ops.push({ + type: "database-add-view", + blockId: plan.blockId, + view: step.view, + }); + knownViewIds.add(step.view.id); + break; + case "set_active_view": + ops.push({ + type: "database-set-active-view", + blockId: plan.blockId, + viewId: step.viewId, + }); + break; + } + } + + if (virtualBlock?.database) { + virtualBlock.database.columnIds = knownColumnIds; + virtualBlock.database.rowIds = knownRowIds; + virtualBlock.database.viewIds = knownViewIds; + } + + return { + ops, + issues: [], + reviewSafe: false, + }; +} + +export function buildReviewBundleExecution( + editor: Editor, + plan: ReviewBundlePlan, + context: PlanExecutionContext, +): PlanExecutionResult { + const ops: DocumentOp[] = []; + const issues: PlanExecutionIssue[] = []; + let reviewSafe = true; + + for (let index = 0; index < plan.plans.length; index += 1) { + const nestedPlan = plan.plans[index]!; + const execution = buildPlanExecution(editor, nestedPlan, context); + ops.push(...execution.ops); + issues.push( + ...execution.issues.map((issue) => ({ + ...issue, + path: `${plan.kind}.plans[${index}].${issue.path}`, + })), + ); + reviewSafe &&= execution.reviewSafe; + } + + return { + ops, + issues, + reviewSafe, + }; +} + +export function createVirtualBlockState( + blockType: string, + props: Record = {}, + text: string | number = 0, +): VirtualBlockState { + const textLength = typeof text === "number" ? text : text.length; + if (blockType === "database") { + return { + type: blockType, + props, + textLength, + database: { + columnIds: new Set(), + rowIds: new Set(), + viewIds: new Set(), + }, + }; + } + return { + type: blockType, + props, + textLength, + }; +} + +export function resolveBlockState( + editor: Editor, + context: PlanExecutionContext, + blockId: string, +): VirtualBlockState | null { + const virtualBlock = context.virtualBlocks.get(blockId) ?? null; + if (virtualBlock) { + return virtualBlock; + } + + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + + const nextState = createVirtualBlockState( + block.type, + { ...block.props }, + block.length(), + ); + if (block.type === "database") { + nextState.database = { + columnIds: new Set(block.tableColumns().map((column) => column.id)), + rowIds: new Set(readDatabaseRowIds(block)), + viewIds: new Set(block.databaseViews().map((view) => view.id)), + }; + } + return nextState; +} + +export function withIssue( + path: string, + code: PlanExecutionIssue["code"], + message: string, +): PlanExecutionResult { + return { + ops: [], + issues: [{ path, code, message }], + reviewSafe: false, + }; +} + +export function stringifyRecord( + value: Record, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + stringifyDatabaseValue(entryValue), + ]), + ); +} diff --git a/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts new file mode 100644 index 0000000..ada586b --- /dev/null +++ b/packages/extensions/ai/src/runtime/planExecutorParts/planExecutorPart5.ts @@ -0,0 +1,58 @@ +// @ts-nocheck +import type { DocumentOp, Editor } from "@pen/types"; +import { buildDocumentWriteOps } from "@pen/document-ops"; +import { generateId } from "@pen/types"; +import type { + BlockConvertPlan, + BlockInsertPlan, + BlockMovePlan, + BlockUpdatePlan, + DatabaseEditPlan, + DocumentMutationPlan, + FlowPatchEdit, + FlowPatchPlan, + ReviewBundlePlan, + TextEditPlan, +} from "../planTypes"; +import { buildDocumentMutationPlanExecution, buildPlanExecution, buildTextEditExecution, buildFlowPatchExecution, buildBlockInsertExecution } from "./planExecutorPart1"; +import type { PlanExecutionIssue, PlanExecutionResult, PlanExecutionMetrics, FlowPatchAlignmentMetrics, VirtualBlockState, PlanExecutionContext, PendingInlineMark, PendingInlineBlock, InlineAlignmentStep, InlineAlignmentResolution } from "./planExecutorPart1"; +import { buildFlowPatchEditExecution, buildOptimizedBlockReplacement, buildInlineBlockRewriteOps, buildInlineAlignmentOps, buildBlockUpdateExecution, isInlineConvertiblePendingBlock, isInlineConvertibleTargetBlock } from "./planExecutorPart2"; +import { resolveInlineAlignmentPlan, shouldPreferInlineSubstitution, estimateInlineSubstituteCost, estimateInlineDeleteCost, estimateInlineInsertCost, estimateInlineBlockRewriteCost, summarizeInlineAlignment, mergeFlowPatchAlignmentMetrics, areBlocksReusableMatch, areTextsReusableMatch, normalizeReusableText, resolveSharedPrefixLength, resolveSharedSuffixLength, resolveLevenshteinDistance } from "./planExecutorPart3"; +import { buildInlinePendingBlockInsertOps, resolveLastInsertedBlockId, resolveInsertionPosition, areRecordValuesEqual, buildBlockMoveExecution, buildBlockConvertExecution, buildDatabaseEditExecution, buildReviewBundleExecution, createVirtualBlockState, resolveBlockState, withIssue, stringifyRecord } from "./planExecutorPart4"; + +export function readDatabaseRowIds( + block: ReturnType, +): string[] { + if (!block) { + return []; + } + const rowIds: string[] = []; + for (let index = 0; index < block.tableRowCount(); index += 1) { + const rowId = block.tableRow(index)?.id; + if (rowId) { + rowIds.push(rowId); + } + } + return rowIds; +} + +export function stringifyDatabaseValue(value: unknown): string { + if (value == null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/extensions/ai/src/runtime/planValidation.ts b/packages/extensions/ai/src/runtime/planValidation.ts index 4fd18df..da4f788 100644 --- a/packages/extensions/ai/src/runtime/planValidation.ts +++ b/packages/extensions/ai/src/runtime/planValidation.ts @@ -1,961 +1,2 @@ -import type { DocumentProfile } from "@pen/types"; -import type { AITargetKind } from "./contracts"; -import { - DOCUMENT_MUTATION_PLAN_KINDS, - type DocumentMutationPlan, - type DocumentMutationPlanKind, -} from "./planTypes"; - -export const PLAN_VALIDATION_SEVERITIES = ["info", "warn", "error"] as const; - -export type PlanValidationSeverity = - (typeof PLAN_VALIDATION_SEVERITIES)[number]; - -export interface PlanValidationIssue { - path: string; - code: - | "missing-field" - | "invalid-kind" - | "invalid-shape" - | "invalid-step" - | "invalid-nested-plan" - | "unsupported-target-kind" - | "unknown-block-type" - | "out-of-scope-target" - | "read-only-target"; - severity: PlanValidationSeverity; - message: string; -} - -export interface PlanValidationContext { - documentProfile?: DocumentProfile; - targetKind?: AITargetKind; - knownBlockTypes?: readonly string[]; - allowedTargetBlockIds?: readonly string[]; - editableTargetBlockIds?: readonly string[]; -} - -export interface PlanValidationResult { - valid: boolean; - issues: PlanValidationIssue[]; -} - -const DOCUMENT_MUTATION_PLAN_KIND_SET = new Set( - DOCUMENT_MUTATION_PLAN_KINDS, -); - -const TEXT_EDIT_OPERATIONS = new Set(["replace", "insert", "append"]); -const FLOW_PATCH_EDIT_OPERATIONS = new Set([ - "replace_text", - "append_text", - "insert_before", - "insert_after", - "replace_blocks", - "delete_blocks", -]); -const DATABASE_EDIT_STEP_OPERATIONS = new Set([ - "add_column", - "update_column", - "insert_row", - "update_cell", - "add_view", - "set_active_view", -]); -const POSITION_LITERALS = new Set(["first", "last"]); - -export function validateDocumentMutationPlanShape( - plan: unknown, - _context?: PlanValidationContext, -): PlanValidationResult { - const issues: PlanValidationIssue[] = []; - validatePlan(plan, "plan", issues); - if (_context) { - validatePlanSemantics(plan, "plan", issues, _context); - } - return { - valid: !issues.some((issue) => issue.severity === "error"), - issues, - }; -} - -export function isDocumentMutationPlan( - value: unknown, -): value is DocumentMutationPlan { - return validateDocumentMutationPlanShape(value).valid; -} - -function validatePlan( - plan: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const record = asRecord(plan); - if (!record) { - pushIssue(issues, path, "invalid-shape", "Plan must be an object."); - return; - } - - if (!isNonEmptyString(record.kind)) { - pushIssue(issues, `${path}.kind`, "missing-field", "Plan kind is required."); - return; - } - - if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { - pushIssue( - issues, - `${path}.kind`, - "invalid-kind", - `Unsupported plan kind "${record.kind}".`, - ); - return; - } - - switch (record.kind as DocumentMutationPlanKind) { - case "text_edit": - validateTextEditPlan(record, path, issues); - return; - case "flow_patch": - validateFlowPatchPlan(record, path, issues); - return; - case "block_insert": - validateBlockInsertPlan(record, path, issues); - return; - case "block_update": - validateBlockUpdatePlan(record, path, issues); - return; - case "block_move": - validateBlockMovePlan(record, path, issues); - return; - case "block_convert": - validateBlockConvertPlan(record, path, issues); - return; - case "database_edit": - validateDatabaseEditPlan(record, path, issues); - return; - case "review_bundle": - validateReviewBundlePlan(record, path, issues); - return; - } -} - -function validateTextEditPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - const target = asRecord(plan.target); - if (!target) { - pushIssue( - issues, - `${path}.target`, - "invalid-shape", - "Text edit target must be an object.", - ); - } else { - requireString(target, "blockId", `${path}.target`, issues); - if (target.range !== undefined) { - validateTextRange(target.range, `${path}.target.range`, issues); - } - } - - if (!isNonEmptyString(plan.operation) || !TEXT_EDIT_OPERATIONS.has(plan.operation)) { - pushIssue( - issues, - `${path}.operation`, - "invalid-shape", - "Text edit operation must be replace, insert, or append.", - ); - } - - requireString(plan, "text", path, issues); - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateFlowPatchPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "instructions", path, issues); - if ( - plan.scope !== undefined && - plan.scope !== "single-block" && - plan.scope !== "adjacent-blocks" && - plan.scope !== "section" - ) { - pushIssue( - issues, - `${path}.scope`, - "invalid-shape", - 'Flow patch scope must be "single-block", "adjacent-blocks", or "section".', - ); - } - if (plan.targetSpanId !== undefined && typeof plan.targetSpanId !== "string") { - pushIssue( - issues, - `${path}.targetSpanId`, - "invalid-shape", - "targetSpanId must be a string when provided.", - ); - } - if (!Array.isArray(plan.edits)) { - pushIssue( - issues, - `${path}.edits`, - "invalid-shape", - "Flow patch edits must be an array.", - ); - } else { - plan.edits.forEach((edit, index) => { - validateFlowPatchEdit(edit, `${path}.edits[${index}]`, issues); - }); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateBlockInsertPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - if (plan.blockId !== undefined && typeof plan.blockId !== "string") { - pushIssue( - issues, - `${path}.blockId`, - "invalid-shape", - "blockId must be a string when provided.", - ); - } - requireString(plan, "blockType", path, issues); - validatePosition(plan.position, `${path}.position`, issues); - if (plan.props !== undefined && !isRecord(plan.props)) { - pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); - } - if (plan.initialText !== undefined && typeof plan.initialText !== "string") { - pushIssue( - issues, - `${path}.initialText`, - "invalid-shape", - "Initial text must be a string.", - ); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateFlowPatchEdit( - edit: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const record = asRecord(edit); - if (!record) { - pushIssue(issues, path, "invalid-shape", "Flow patch edit must be an object."); - return; - } - if ( - !isNonEmptyString(record.operation) || - !FLOW_PATCH_EDIT_OPERATIONS.has(record.operation) - ) { - pushIssue( - issues, - `${path}.operation`, - "invalid-shape", - "Flow patch edit operation is unsupported.", - ); - } - const locator = asRecord(record.locator); - if (!locator) { - pushIssue( - issues, - `${path}.locator`, - "invalid-shape", - "Flow patch edit locator must be an object.", - ); - } else { - if (locator.blockId !== undefined && typeof locator.blockId !== "string") { - pushIssue( - issues, - `${path}.locator.blockId`, - "invalid-shape", - "blockId must be a string when provided.", - ); - } - if ( - locator.blockIds !== undefined && - (!Array.isArray(locator.blockIds) || - !locator.blockIds.every((blockId) => typeof blockId === "string")) - ) { - pushIssue( - issues, - `${path}.locator.blockIds`, - "invalid-shape", - "blockIds must be an array of strings when provided.", - ); - } - for (const field of [ - "retrievedSpanId", - "expectedBlockType", - "anchorBefore", - "anchorAfter", - ] as const) { - if (locator[field] !== undefined && typeof locator[field] !== "string") { - pushIssue( - issues, - `${path}.locator.${field}`, - "invalid-shape", - `${field} must be a string when provided.`, - ); - } - } - } - - if (record.text !== undefined && typeof record.text !== "string") { - pushIssue( - issues, - `${path}.text`, - "invalid-shape", - "text must be a string when provided.", - ); - } - if (record.markdown !== undefined && typeof record.markdown !== "string") { - pushIssue( - issues, - `${path}.markdown`, - "invalid-shape", - "markdown must be a string when provided.", - ); - } - validateConfidence(record.confidence, `${path}.confidence`, issues); -} - -function validateBlockUpdatePlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - if (!isRecord(plan.props)) { - pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateBlockMovePlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - validatePosition(plan.position, `${path}.position`, issues); - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateBlockConvertPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - requireString(plan, "newType", path, issues); - if (plan.props !== undefined && !isRecord(plan.props)) { - pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateDatabaseEditPlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "blockId", path, issues); - if (!Array.isArray(plan.steps)) { - pushIssue( - issues, - `${path}.steps`, - "invalid-shape", - "Database edit steps must be an array.", - ); - } else { - plan.steps.forEach((step, index) => { - validateDatabaseEditStep(step, `${path}.steps[${index}]`, issues); - }); - } - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validateReviewBundlePlan( - plan: Record, - path: string, - issues: PlanValidationIssue[], -): void { - requireString(plan, "label", path, issues); - requireString(plan, "reason", path, issues); - - if (!Array.isArray(plan.plans)) { - pushIssue( - issues, - `${path}.plans`, - "invalid-shape", - "Review bundle plans must be an array.", - ); - } else { - plan.plans.forEach((childPlan, index) => { - const childIssuesBefore = issues.length; - validatePlan(childPlan, `${path}.plans[${index}]`, issues); - if (issues.length > childIssuesBefore) { - pushIssue( - issues, - `${path}.plans[${index}]`, - "invalid-nested-plan", - "Review bundle contains an invalid nested plan.", - ); - } - }); - } - - validateConfidence(plan.confidence, `${path}.confidence`, issues); -} - -function validatePlanSemantics( - plan: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - const record = asRecord(plan); - if (!record || !isNonEmptyString(record.kind)) { - return; - } - - if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { - return; - } - - const kind = record.kind as DocumentMutationPlanKind; - validateTargetKindCompatibility(kind, path, issues, context); - - switch (kind) { - case "text_edit": { - const target = asRecord(record.target); - if (!target) { - return; - } - validateMutableTargetBlockReference( - target.blockId, - `${path}.target.blockId`, - issues, - context, - ); - return; - } - case "flow_patch": { - if (!Array.isArray(record.edits)) { - return; - } - record.edits.forEach((edit, index) => { - validateFlowPatchEditSemantics( - edit, - `${path}.edits[${index}]`, - issues, - context, - ); - }); - return; - } - case "block_insert": - validateKnownBlockType(record.blockType, `${path}.blockType`, issues, context); - validatePositionSemantics(record.position, `${path}.position`, issues, context); - return; - case "block_update": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - return; - case "block_move": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - validatePositionSemantics(record.position, `${path}.position`, issues, context); - return; - case "block_convert": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - validateKnownBlockType(record.newType, `${path}.newType`, issues, context); - return; - case "database_edit": - validateMutableTargetBlockReference( - record.blockId, - `${path}.blockId`, - issues, - context, - ); - return; - case "review_bundle": - if (!Array.isArray(record.plans)) { - return; - } - record.plans.forEach((childPlan, index) => { - validatePlanSemantics( - childPlan, - `${path}.plans[${index}]`, - issues, - context, - ); - }); - return; - } -} - -function validateTargetKindCompatibility( - kind: DocumentMutationPlanKind, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if (!context.targetKind) { - return; - } - - if (isPlanKindAllowedForTarget(kind, context.targetKind)) { - return; - } - - pushIssue( - issues, - `${path}.kind`, - "unsupported-target-kind", - `Plan kind "${kind}" is not supported for ${context.targetKind} targets.`, - ); -} - -function validateFlowPatchEditSemantics( - edit: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - const record = asRecord(edit); - if (!record) { - return; - } - - const locator = asRecord(record.locator); - if (!locator) { - return; - } - - validateMutableTargetBlockReference( - locator.blockId, - `${path}.locator.blockId`, - issues, - context, - ); - - if (Array.isArray(locator.blockIds)) { - locator.blockIds.forEach((blockId, index) => { - validateMutableTargetBlockReference( - blockId, - `${path}.locator.blockIds[${index}]`, - issues, - context, - ); - }); - } - - validateScopedBlockReference( - locator.anchorBefore, - `${path}.locator.anchorBefore`, - issues, - context, - ); - validateScopedBlockReference( - locator.anchorAfter, - `${path}.locator.anchorAfter`, - issues, - context, - ); - validateKnownBlockType( - locator.expectedBlockType, - `${path}.locator.expectedBlockType`, - issues, - context, - ); -} - -function validatePositionSemantics( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - const position = asRecord(value); - if (!position) { - return; - } - - validateScopedBlockReference(position.before, `${path}.before`, issues, context); - validateScopedBlockReference(position.after, `${path}.after`, issues, context); - validateScopedBlockReference(position.parent, `${path}.parent`, issues, context); -} - -function validateKnownBlockType( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if ( - !isNonEmptyString(value) || - !context.knownBlockTypes || - context.knownBlockTypes.includes(value) - ) { - return; - } - - pushIssue( - issues, - path, - "unknown-block-type", - `Block type "${value}" is not available in ${context.documentProfile ?? "this"} documents.`, - ); -} - -function validateMutableTargetBlockReference( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if (!isNonEmptyString(value)) { - return; - } - - if ( - context.allowedTargetBlockIds && - !context.allowedTargetBlockIds.includes(value) - ) { - pushIssue( - issues, - path, - "out-of-scope-target", - `Block "${value}" is outside the validated mutation scope.`, - ); - return; - } - - if ( - context.editableTargetBlockIds && - !context.editableTargetBlockIds.includes(value) - ) { - pushIssue( - issues, - path, - "read-only-target", - `Block "${value}" is not editable in ${context.documentProfile ?? "this"} documents.`, - ); - } -} - -function validateScopedBlockReference( - value: unknown, - path: string, - issues: PlanValidationIssue[], - context: PlanValidationContext, -): void { - if ( - !isNonEmptyString(value) || - !context.allowedTargetBlockIds || - context.allowedTargetBlockIds.includes(value) - ) { - return; - } - - pushIssue( - issues, - path, - "out-of-scope-target", - `Block "${value}" is outside the validated mutation scope.`, - ); -} - -function validateDatabaseEditStep( - step: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const record = asRecord(step); - if (!record) { - pushIssue( - issues, - path, - "invalid-step", - "Database edit step must be an object.", - ); - return; - } - - if ( - !isNonEmptyString(record.op) || - !DATABASE_EDIT_STEP_OPERATIONS.has(record.op) - ) { - pushIssue( - issues, - `${path}.op`, - "invalid-step", - "Unsupported database edit step operation.", - ); - return; - } - - switch (record.op) { - case "add_column": - if (!isRecord(record.column)) { - pushIssue( - issues, - `${path}.column`, - "invalid-step", - "Column must be an object.", - ); - } - return; - case "update_column": - requireString(record, "columnId", path, issues); - if (!isRecord(record.patch)) { - pushIssue( - issues, - `${path}.patch`, - "invalid-step", - "Column patch must be an object.", - ); - } - return; - case "insert_row": - if (record.rowId !== undefined && typeof record.rowId !== "string") { - pushIssue( - issues, - `${path}.rowId`, - "invalid-step", - "Row id must be a string.", - ); - } - if (!isRecord(record.values)) { - pushIssue( - issues, - `${path}.values`, - "invalid-step", - "Row values must be an object.", - ); - } - return; - case "update_cell": - requireString(record, "rowId", path, issues); - requireString(record, "columnId", path, issues); - return; - case "add_view": - if (!isRecord(record.view)) { - pushIssue( - issues, - `${path}.view`, - "invalid-step", - "View must be an object.", - ); - } - return; - case "set_active_view": - requireString(record, "viewId", path, issues); - return; - } -} - -function validateTextRange( - value: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - const range = asRecord(value); - if (!range) { - pushIssue(issues, path, "invalid-shape", "Range must be an object."); - return; - } - - requireNumber(range, "startOffset", path, issues); - requireNumber(range, "endOffset", path, issues); -} - -function validateConfidence( - value: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - if (value === undefined) { - return; - } - - const confidence = asRecord(value); - if (!confidence) { - pushIssue( - issues, - path, - "invalid-shape", - "Confidence must be an object when provided.", - ); - return; - } - - if (confidence.score !== undefined && !isFiniteNumber(confidence.score)) { - pushIssue( - issues, - `${path}.score`, - "invalid-shape", - "Confidence score must be a number.", - ); - } - if (confidence.reason !== undefined && typeof confidence.reason !== "string") { - pushIssue( - issues, - `${path}.reason`, - "invalid-shape", - "Confidence reason must be a string.", - ); - } -} - -function validatePosition( - value: unknown, - path: string, - issues: PlanValidationIssue[], -): void { - if (typeof value === "string") { - if (POSITION_LITERALS.has(value)) { - return; - } - pushIssue(issues, path, "invalid-shape", "Position string is invalid."); - return; - } - - const position = asRecord(value); - if (!position) { - pushIssue(issues, path, "invalid-shape", "Position must be an object."); - return; - } - - if (isNonEmptyString(position.before)) { - return; - } - if (isNonEmptyString(position.after)) { - return; - } - if (isNonEmptyString(position.parent) && isFiniteNumber(position.index)) { - return; - } - - pushIssue(issues, path, "invalid-shape", "Position object is invalid."); -} - -function requireString( - record: Record, - field: string, - path: string, - issues: PlanValidationIssue[], -): void { - const value = record[field]; - if (typeof value === "string" && value.length > 0) { - return; - } - - pushIssue( - issues, - `${path}.${field}`, - value === undefined ? "missing-field" : "invalid-shape", - `${field} must be a non-empty string.`, - ); -} - -function requireNumber( - record: Record, - field: string, - path: string, - issues: PlanValidationIssue[], -): void { - const value = record[field]; - if (isFiniteNumber(value)) { - return; - } - - pushIssue( - issues, - `${path}.${field}`, - value === undefined ? "missing-field" : "invalid-shape", - `${field} must be a number.`, - ); -} - -function isPlanKindAllowedForTarget( - kind: DocumentMutationPlanKind, - targetKind: AITargetKind, -): boolean { - switch (targetKind) { - case "database": - return ( - kind === "block_insert" || - kind === "block_update" || - kind === "block_move" || - kind === "block_convert" || - kind === "database_edit" || - kind === "review_bundle" - ); - case "text": - return kind === "text_edit" || kind === "flow_patch" || kind === "review_bundle"; - case "block": - return kind !== "database_edit"; - case "table": - return ( - kind === "flow_patch" || - kind === "block_update" || - kind === "block_move" || - kind === "block_convert" || - kind === "review_bundle" - ); - } -} - -function pushIssue( - issues: PlanValidationIssue[], - path: string, - code: PlanValidationIssue["code"], - message: string, -): void { - issues.push({ - path, - code, - severity: "error", - message, - }); -} - -function asRecord(value: unknown): Record | null { - return isRecord(value) ? value : null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} +export { PLAN_VALIDATION_SEVERITIES, validateDocumentMutationPlanShape, isDocumentMutationPlan } from "./planValidationParts/planValidationPart1"; +export type { PlanValidationSeverity, PlanValidationIssue, PlanValidationContext, PlanValidationResult } from "./planValidationParts/planValidationPart1"; diff --git a/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts new file mode 100644 index 0000000..68aa85a --- /dev/null +++ b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart1.ts @@ -0,0 +1,370 @@ +// @ts-nocheck +import type { DocumentProfile } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import { + DOCUMENT_MUTATION_PLAN_KINDS, + type DocumentMutationPlan, + type DocumentMutationPlanKind, +} from "../planTypes"; +import { validateDatabaseEditPlan, validateReviewBundlePlan, validatePlanSemantics, validateTargetKindCompatibility, validateFlowPatchEditSemantics, validatePositionSemantics, validateKnownBlockType, validateMutableTargetBlockReference, validateScopedBlockReference } from "./planValidationPart2"; +import { validateDatabaseEditStep, validateTextRange, validateConfidence, validatePosition, requireString, requireNumber, isPlanKindAllowedForTarget, pushIssue, asRecord, isRecord, isFiniteNumber, isNonEmptyString } from "./planValidationPart3"; + +export const PLAN_VALIDATION_SEVERITIES = ["info", "warn", "error"] as const; + +export type PlanValidationSeverity = + (typeof PLAN_VALIDATION_SEVERITIES)[number]; + +export interface PlanValidationIssue { + path: string; + code: + | "missing-field" + | "invalid-kind" + | "invalid-shape" + | "invalid-step" + | "invalid-nested-plan" + | "unsupported-target-kind" + | "unknown-block-type" + | "out-of-scope-target" + | "read-only-target"; + severity: PlanValidationSeverity; + message: string; +} + +export interface PlanValidationContext { + documentProfile?: DocumentProfile; + targetKind?: AITargetKind; + knownBlockTypes?: readonly string[]; + allowedTargetBlockIds?: readonly string[]; + editableTargetBlockIds?: readonly string[]; +} + +export interface PlanValidationResult { + valid: boolean; + issues: PlanValidationIssue[]; +} + +export const DOCUMENT_MUTATION_PLAN_KIND_SET = new Set( + DOCUMENT_MUTATION_PLAN_KINDS, +); + +export const TEXT_EDIT_OPERATIONS = new Set(["replace", "insert", "append"]); + +export const FLOW_PATCH_EDIT_OPERATIONS = new Set([ + "replace_text", + "append_text", + "insert_before", + "insert_after", + "replace_blocks", + "delete_blocks", +]); + +export const DATABASE_EDIT_STEP_OPERATIONS = new Set([ + "add_column", + "update_column", + "insert_row", + "update_cell", + "add_view", + "set_active_view", +]); + +export const POSITION_LITERALS = new Set(["first", "last"]); + +export function validateDocumentMutationPlanShape( + plan: unknown, + _context?: PlanValidationContext, +): PlanValidationResult { + const issues: PlanValidationIssue[] = []; + validatePlan(plan, "plan", issues); + if (_context) { + validatePlanSemantics(plan, "plan", issues, _context); + } + return { + valid: !issues.some((issue) => issue.severity === "error"), + issues, + }; +} + +export function isDocumentMutationPlan( + value: unknown, +): value is DocumentMutationPlan { + return validateDocumentMutationPlanShape(value).valid; +} + +export function validatePlan( + plan: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const record = asRecord(plan); + if (!record) { + pushIssue(issues, path, "invalid-shape", "Plan must be an object."); + return; + } + + if (!isNonEmptyString(record.kind)) { + pushIssue(issues, `${path}.kind`, "missing-field", "Plan kind is required."); + return; + } + + if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { + pushIssue( + issues, + `${path}.kind`, + "invalid-kind", + `Unsupported plan kind "${record.kind}".`, + ); + return; + } + + switch (record.kind as DocumentMutationPlanKind) { + case "text_edit": + validateTextEditPlan(record, path, issues); + return; + case "flow_patch": + validateFlowPatchPlan(record, path, issues); + return; + case "block_insert": + validateBlockInsertPlan(record, path, issues); + return; + case "block_update": + validateBlockUpdatePlan(record, path, issues); + return; + case "block_move": + validateBlockMovePlan(record, path, issues); + return; + case "block_convert": + validateBlockConvertPlan(record, path, issues); + return; + case "database_edit": + validateDatabaseEditPlan(record, path, issues); + return; + case "review_bundle": + validateReviewBundlePlan(record, path, issues); + return; + } +} + +export function validateTextEditPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + const target = asRecord(plan.target); + if (!target) { + pushIssue( + issues, + `${path}.target`, + "invalid-shape", + "Text edit target must be an object.", + ); + } else { + requireString(target, "blockId", `${path}.target`, issues); + if (target.range !== undefined) { + validateTextRange(target.range, `${path}.target.range`, issues); + } + } + + if (!isNonEmptyString(plan.operation) || !TEXT_EDIT_OPERATIONS.has(plan.operation)) { + pushIssue( + issues, + `${path}.operation`, + "invalid-shape", + "Text edit operation must be replace, insert, or append.", + ); + } + + requireString(plan, "text", path, issues); + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateFlowPatchPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "instructions", path, issues); + if ( + plan.scope !== undefined && + plan.scope !== "single-block" && + plan.scope !== "adjacent-blocks" && + plan.scope !== "section" + ) { + pushIssue( + issues, + `${path}.scope`, + "invalid-shape", + 'Flow patch scope must be "single-block", "adjacent-blocks", or "section".', + ); + } + if (plan.targetSpanId !== undefined && typeof plan.targetSpanId !== "string") { + pushIssue( + issues, + `${path}.targetSpanId`, + "invalid-shape", + "targetSpanId must be a string when provided.", + ); + } + if (!Array.isArray(plan.edits)) { + pushIssue( + issues, + `${path}.edits`, + "invalid-shape", + "Flow patch edits must be an array.", + ); + } else { + plan.edits.forEach((edit, index) => { + validateFlowPatchEdit(edit, `${path}.edits[${index}]`, issues); + }); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateBlockInsertPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + if (plan.blockId !== undefined && typeof plan.blockId !== "string") { + pushIssue( + issues, + `${path}.blockId`, + "invalid-shape", + "blockId must be a string when provided.", + ); + } + requireString(plan, "blockType", path, issues); + validatePosition(plan.position, `${path}.position`, issues); + if (plan.props !== undefined && !isRecord(plan.props)) { + pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); + } + if (plan.initialText !== undefined && typeof plan.initialText !== "string") { + pushIssue( + issues, + `${path}.initialText`, + "invalid-shape", + "Initial text must be a string.", + ); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateFlowPatchEdit( + edit: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const record = asRecord(edit); + if (!record) { + pushIssue(issues, path, "invalid-shape", "Flow patch edit must be an object."); + return; + } + if ( + !isNonEmptyString(record.operation) || + !FLOW_PATCH_EDIT_OPERATIONS.has(record.operation) + ) { + pushIssue( + issues, + `${path}.operation`, + "invalid-shape", + "Flow patch edit operation is unsupported.", + ); + } + const locator = asRecord(record.locator); + if (!locator) { + pushIssue( + issues, + `${path}.locator`, + "invalid-shape", + "Flow patch edit locator must be an object.", + ); + } else { + if (locator.blockId !== undefined && typeof locator.blockId !== "string") { + pushIssue( + issues, + `${path}.locator.blockId`, + "invalid-shape", + "blockId must be a string when provided.", + ); + } + if ( + locator.blockIds !== undefined && + (!Array.isArray(locator.blockIds) || + !locator.blockIds.every((blockId) => typeof blockId === "string")) + ) { + pushIssue( + issues, + `${path}.locator.blockIds`, + "invalid-shape", + "blockIds must be an array of strings when provided.", + ); + } + for (const field of [ + "retrievedSpanId", + "expectedBlockType", + "anchorBefore", + "anchorAfter", + ] as const) { + if (locator[field] !== undefined && typeof locator[field] !== "string") { + pushIssue( + issues, + `${path}.locator.${field}`, + "invalid-shape", + `${field} must be a string when provided.`, + ); + } + } + } + + if (record.text !== undefined && typeof record.text !== "string") { + pushIssue( + issues, + `${path}.text`, + "invalid-shape", + "text must be a string when provided.", + ); + } + if (record.markdown !== undefined && typeof record.markdown !== "string") { + pushIssue( + issues, + `${path}.markdown`, + "invalid-shape", + "markdown must be a string when provided.", + ); + } + validateConfidence(record.confidence, `${path}.confidence`, issues); +} + +export function validateBlockUpdatePlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + if (!isRecord(plan.props)) { + pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateBlockMovePlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + validatePosition(plan.position, `${path}.position`, issues); + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateBlockConvertPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + requireString(plan, "newType", path, issues); + if (plan.props !== undefined && !isRecord(plan.props)) { + pushIssue(issues, `${path}.props`, "invalid-shape", "Props must be an object."); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} diff --git a/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts new file mode 100644 index 0000000..fd35321 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart2.ts @@ -0,0 +1,337 @@ +// @ts-nocheck +import type { DocumentProfile } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import { + DOCUMENT_MUTATION_PLAN_KINDS, + type DocumentMutationPlan, + type DocumentMutationPlanKind, +} from "../planTypes"; +import { PLAN_VALIDATION_SEVERITIES, DOCUMENT_MUTATION_PLAN_KIND_SET, TEXT_EDIT_OPERATIONS, FLOW_PATCH_EDIT_OPERATIONS, DATABASE_EDIT_STEP_OPERATIONS, POSITION_LITERALS, validateDocumentMutationPlanShape, isDocumentMutationPlan, validatePlan, validateTextEditPlan, validateFlowPatchPlan, validateBlockInsertPlan, validateFlowPatchEdit, validateBlockUpdatePlan, validateBlockMovePlan, validateBlockConvertPlan } from "./planValidationPart1"; +import type { PlanValidationSeverity, PlanValidationIssue, PlanValidationContext, PlanValidationResult } from "./planValidationPart1"; +import { validateDatabaseEditStep, validateTextRange, validateConfidence, validatePosition, requireString, requireNumber, isPlanKindAllowedForTarget, pushIssue, asRecord, isRecord, isFiniteNumber, isNonEmptyString } from "./planValidationPart3"; + +export function validateDatabaseEditPlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "blockId", path, issues); + if (!Array.isArray(plan.steps)) { + pushIssue( + issues, + `${path}.steps`, + "invalid-shape", + "Database edit steps must be an array.", + ); + } else { + plan.steps.forEach((step, index) => { + validateDatabaseEditStep(step, `${path}.steps[${index}]`, issues); + }); + } + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validateReviewBundlePlan( + plan: Record, + path: string, + issues: PlanValidationIssue[], +): void { + requireString(plan, "label", path, issues); + requireString(plan, "reason", path, issues); + + if (!Array.isArray(plan.plans)) { + pushIssue( + issues, + `${path}.plans`, + "invalid-shape", + "Review bundle plans must be an array.", + ); + } else { + plan.plans.forEach((childPlan, index) => { + const childIssuesBefore = issues.length; + validatePlan(childPlan, `${path}.plans[${index}]`, issues); + if (issues.length > childIssuesBefore) { + pushIssue( + issues, + `${path}.plans[${index}]`, + "invalid-nested-plan", + "Review bundle contains an invalid nested plan.", + ); + } + }); + } + + validateConfidence(plan.confidence, `${path}.confidence`, issues); +} + +export function validatePlanSemantics( + plan: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + const record = asRecord(plan); + if (!record || !isNonEmptyString(record.kind)) { + return; + } + + if (!DOCUMENT_MUTATION_PLAN_KIND_SET.has(record.kind)) { + return; + } + + const kind = record.kind as DocumentMutationPlanKind; + validateTargetKindCompatibility(kind, path, issues, context); + + switch (kind) { + case "text_edit": { + const target = asRecord(record.target); + if (!target) { + return; + } + validateMutableTargetBlockReference( + target.blockId, + `${path}.target.blockId`, + issues, + context, + ); + return; + } + case "flow_patch": { + if (!Array.isArray(record.edits)) { + return; + } + record.edits.forEach((edit, index) => { + validateFlowPatchEditSemantics( + edit, + `${path}.edits[${index}]`, + issues, + context, + ); + }); + return; + } + case "block_insert": + validateKnownBlockType(record.blockType, `${path}.blockType`, issues, context); + validatePositionSemantics(record.position, `${path}.position`, issues, context); + return; + case "block_update": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + return; + case "block_move": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + validatePositionSemantics(record.position, `${path}.position`, issues, context); + return; + case "block_convert": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + validateKnownBlockType(record.newType, `${path}.newType`, issues, context); + return; + case "database_edit": + validateMutableTargetBlockReference( + record.blockId, + `${path}.blockId`, + issues, + context, + ); + return; + case "review_bundle": + if (!Array.isArray(record.plans)) { + return; + } + record.plans.forEach((childPlan, index) => { + validatePlanSemantics( + childPlan, + `${path}.plans[${index}]`, + issues, + context, + ); + }); + return; + } +} + +export function validateTargetKindCompatibility( + kind: DocumentMutationPlanKind, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if (!context.targetKind) { + return; + } + + if (isPlanKindAllowedForTarget(kind, context.targetKind)) { + return; + } + + pushIssue( + issues, + `${path}.kind`, + "unsupported-target-kind", + `Plan kind "${kind}" is not supported for ${context.targetKind} targets.`, + ); +} + +export function validateFlowPatchEditSemantics( + edit: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + const record = asRecord(edit); + if (!record) { + return; + } + + const locator = asRecord(record.locator); + if (!locator) { + return; + } + + validateMutableTargetBlockReference( + locator.blockId, + `${path}.locator.blockId`, + issues, + context, + ); + + if (Array.isArray(locator.blockIds)) { + locator.blockIds.forEach((blockId, index) => { + validateMutableTargetBlockReference( + blockId, + `${path}.locator.blockIds[${index}]`, + issues, + context, + ); + }); + } + + validateScopedBlockReference( + locator.anchorBefore, + `${path}.locator.anchorBefore`, + issues, + context, + ); + validateScopedBlockReference( + locator.anchorAfter, + `${path}.locator.anchorAfter`, + issues, + context, + ); + validateKnownBlockType( + locator.expectedBlockType, + `${path}.locator.expectedBlockType`, + issues, + context, + ); +} + +export function validatePositionSemantics( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + const position = asRecord(value); + if (!position) { + return; + } + + validateScopedBlockReference(position.before, `${path}.before`, issues, context); + validateScopedBlockReference(position.after, `${path}.after`, issues, context); + validateScopedBlockReference(position.parent, `${path}.parent`, issues, context); +} + +export function validateKnownBlockType( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if ( + !isNonEmptyString(value) || + !context.knownBlockTypes || + context.knownBlockTypes.includes(value) + ) { + return; + } + + pushIssue( + issues, + path, + "unknown-block-type", + `Block type "${value}" is not available in ${context.documentProfile ?? "this"} documents.`, + ); +} + +export function validateMutableTargetBlockReference( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if (!isNonEmptyString(value)) { + return; + } + + if ( + context.allowedTargetBlockIds && + !context.allowedTargetBlockIds.includes(value) + ) { + pushIssue( + issues, + path, + "out-of-scope-target", + `Block "${value}" is outside the validated mutation scope.`, + ); + return; + } + + if ( + context.editableTargetBlockIds && + !context.editableTargetBlockIds.includes(value) + ) { + pushIssue( + issues, + path, + "read-only-target", + `Block "${value}" is not editable in ${context.documentProfile ?? "this"} documents.`, + ); + } +} + +export function validateScopedBlockReference( + value: unknown, + path: string, + issues: PlanValidationIssue[], + context: PlanValidationContext, +): void { + if ( + !isNonEmptyString(value) || + !context.allowedTargetBlockIds || + context.allowedTargetBlockIds.includes(value) + ) { + return; + } + + pushIssue( + issues, + path, + "out-of-scope-target", + `Block "${value}" is outside the validated mutation scope.`, + ); +} diff --git a/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts new file mode 100644 index 0000000..2f0a8d9 --- /dev/null +++ b/packages/extensions/ai/src/runtime/planValidationParts/planValidationPart3.ts @@ -0,0 +1,282 @@ +// @ts-nocheck +import type { DocumentProfile } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import { + DOCUMENT_MUTATION_PLAN_KINDS, + type DocumentMutationPlan, + type DocumentMutationPlanKind, +} from "../planTypes"; +import { PLAN_VALIDATION_SEVERITIES, DOCUMENT_MUTATION_PLAN_KIND_SET, TEXT_EDIT_OPERATIONS, FLOW_PATCH_EDIT_OPERATIONS, DATABASE_EDIT_STEP_OPERATIONS, POSITION_LITERALS, validateDocumentMutationPlanShape, isDocumentMutationPlan, validatePlan, validateTextEditPlan, validateFlowPatchPlan, validateBlockInsertPlan, validateFlowPatchEdit, validateBlockUpdatePlan, validateBlockMovePlan, validateBlockConvertPlan } from "./planValidationPart1"; +import type { PlanValidationSeverity, PlanValidationIssue, PlanValidationContext, PlanValidationResult } from "./planValidationPart1"; +import { validateDatabaseEditPlan, validateReviewBundlePlan, validatePlanSemantics, validateTargetKindCompatibility, validateFlowPatchEditSemantics, validatePositionSemantics, validateKnownBlockType, validateMutableTargetBlockReference, validateScopedBlockReference } from "./planValidationPart2"; + +export function validateDatabaseEditStep( + step: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const record = asRecord(step); + if (!record) { + pushIssue( + issues, + path, + "invalid-step", + "Database edit step must be an object.", + ); + return; + } + + if ( + !isNonEmptyString(record.op) || + !DATABASE_EDIT_STEP_OPERATIONS.has(record.op) + ) { + pushIssue( + issues, + `${path}.op`, + "invalid-step", + "Unsupported database edit step operation.", + ); + return; + } + + switch (record.op) { + case "add_column": + if (!isRecord(record.column)) { + pushIssue( + issues, + `${path}.column`, + "invalid-step", + "Column must be an object.", + ); + } + return; + case "update_column": + requireString(record, "columnId", path, issues); + if (!isRecord(record.patch)) { + pushIssue( + issues, + `${path}.patch`, + "invalid-step", + "Column patch must be an object.", + ); + } + return; + case "insert_row": + if (record.rowId !== undefined && typeof record.rowId !== "string") { + pushIssue( + issues, + `${path}.rowId`, + "invalid-step", + "Row id must be a string.", + ); + } + if (!isRecord(record.values)) { + pushIssue( + issues, + `${path}.values`, + "invalid-step", + "Row values must be an object.", + ); + } + return; + case "update_cell": + requireString(record, "rowId", path, issues); + requireString(record, "columnId", path, issues); + return; + case "add_view": + if (!isRecord(record.view)) { + pushIssue( + issues, + `${path}.view`, + "invalid-step", + "View must be an object.", + ); + } + return; + case "set_active_view": + requireString(record, "viewId", path, issues); + return; + } +} + +export function validateTextRange( + value: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + const range = asRecord(value); + if (!range) { + pushIssue(issues, path, "invalid-shape", "Range must be an object."); + return; + } + + requireNumber(range, "startOffset", path, issues); + requireNumber(range, "endOffset", path, issues); +} + +export function validateConfidence( + value: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + if (value === undefined) { + return; + } + + const confidence = asRecord(value); + if (!confidence) { + pushIssue( + issues, + path, + "invalid-shape", + "Confidence must be an object when provided.", + ); + return; + } + + if (confidence.score !== undefined && !isFiniteNumber(confidence.score)) { + pushIssue( + issues, + `${path}.score`, + "invalid-shape", + "Confidence score must be a number.", + ); + } + if (confidence.reason !== undefined && typeof confidence.reason !== "string") { + pushIssue( + issues, + `${path}.reason`, + "invalid-shape", + "Confidence reason must be a string.", + ); + } +} + +export function validatePosition( + value: unknown, + path: string, + issues: PlanValidationIssue[], +): void { + if (typeof value === "string") { + if (POSITION_LITERALS.has(value)) { + return; + } + pushIssue(issues, path, "invalid-shape", "Position string is invalid."); + return; + } + + const position = asRecord(value); + if (!position) { + pushIssue(issues, path, "invalid-shape", "Position must be an object."); + return; + } + + if (isNonEmptyString(position.before)) { + return; + } + if (isNonEmptyString(position.after)) { + return; + } + if (isNonEmptyString(position.parent) && isFiniteNumber(position.index)) { + return; + } + + pushIssue(issues, path, "invalid-shape", "Position object is invalid."); +} + +export function requireString( + record: Record, + field: string, + path: string, + issues: PlanValidationIssue[], +): void { + const value = record[field]; + if (typeof value === "string" && value.length > 0) { + return; + } + + pushIssue( + issues, + `${path}.${field}`, + value === undefined ? "missing-field" : "invalid-shape", + `${field} must be a non-empty string.`, + ); +} + +export function requireNumber( + record: Record, + field: string, + path: string, + issues: PlanValidationIssue[], +): void { + const value = record[field]; + if (isFiniteNumber(value)) { + return; + } + + pushIssue( + issues, + `${path}.${field}`, + value === undefined ? "missing-field" : "invalid-shape", + `${field} must be a number.`, + ); +} + +export function isPlanKindAllowedForTarget( + kind: DocumentMutationPlanKind, + targetKind: AITargetKind, +): boolean { + switch (targetKind) { + case "database": + return ( + kind === "block_insert" || + kind === "block_update" || + kind === "block_move" || + kind === "block_convert" || + kind === "database_edit" || + kind === "review_bundle" + ); + case "text": + return kind === "text_edit" || kind === "flow_patch" || kind === "review_bundle"; + case "block": + return kind !== "database_edit"; + case "table": + return ( + kind === "flow_patch" || + kind === "block_update" || + kind === "block_move" || + kind === "block_convert" || + kind === "review_bundle" + ); + } +} + +export function pushIssue( + issues: PlanValidationIssue[], + path: string, + code: PlanValidationIssue["code"], + message: string, +): void { + issues.push({ + path, + code, + severity: "error", + message, + }); +} + +export function asRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/extensions/ai/src/runtime/playgroundPlanner.ts b/packages/extensions/ai/src/runtime/playgroundPlanner.ts index 7586acc..0d9748f 100644 --- a/packages/extensions/ai/src/runtime/playgroundPlanner.ts +++ b/packages/extensions/ai/src/runtime/playgroundPlanner.ts @@ -1,825 +1,3 @@ -import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; -import { parseStructuredIntentRequestPrompt } from "./structuredIntent"; - -export interface PlaygroundPromptContextEnvelope { - json: string; - jsonBytes: number; - estimatedJsonTokens: number; -} - -export type PlaygroundRequestMode = - | "document-agent" - | "structured-generation" - | "selection-fast" - | "inline-autocomplete"; -export type PlaygroundResolvedContextFormat = "json" | "none"; - -export interface PlaygroundRequestPlan { - mode: PlaygroundRequestMode; - modelId: string; - contextFormat: PlaygroundResolvedContextFormat; - systemPrompt: string; - prompt: string; - maxOutputTokens?: number; - temperature?: number; - stopSequences?: string[]; - useTools: boolean; - promptContext: PlaygroundPromptContextEnvelope | null; - selectedTextLength: number | null; -} - -export interface PlaygroundPlannerConfig { - documentModel: string; - selectionModel: string; - documentSystemPrompt: string; - structuredPlannerSystemPrompt: string; - selectionFastPathSystemPrompt: string; - autocompleteSystemPrompt: string; - selectionSourceCharLimit: number; - selectionStopSentinel: string; - selectionOutputTokenCap: number; - autocompleteOutputTokenCap: number; - selectionDefaultOutputTokens: number; - selectionExpandOutputTokens: number; - selectionSummarizeOutputTokens: number; - selectionTranslateOutputTokens: number; -} - -const NEARBY_BLOCK_RADIUS = 2; -const STRUCTURED_PLANNER_PROMPT_PREFIX = - "Produce a structured Pen document mutation plan."; -const EXPLICIT_SELECTION_FAST_REQUEST_ERROR = - "Explicit selection-fast requests require a live or pinned text selection."; -const SESSION_PROMPT_HISTORY_HEADER = "Earlier user requests in this same session:\n"; -const SESSION_PROMPT_LATEST_HEADER = "\nLatest request:\n"; -const SESSION_PROMPT_INTROS = new Set([ - "You are continuing an existing inline editor edit session.", - "You are continuing an existing editor chat session.", -]); -const utf8Encoder = new TextEncoder(); - -export function buildPlaygroundRequestPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedMode: PlaygroundRequestMode | null = null, - requestedOperation: ModelRequestedOperation | null = null, -): PlaygroundRequestPlan { - const explicitPlan = buildExplicitRequestPlan( - editor, - prompt, - config, - requestedMode, - requestedOperation, - ); - if (explicitPlan) { - return explicitPlan; - } - if (parseStructuredIntentRequestPrompt(prompt)) { - return buildStructuredGenerationPlan(prompt, config); - } - - const inlineAutocompletePlan = buildInlineAutocompletePlan(prompt, config); - if (inlineAutocompletePlan) { - return inlineAutocompletePlan; - } - - const selectionPlan = buildSelectionFastPathPlan(editor, prompt, config); - if (selectionPlan) { - return selectionPlan; - } - - if (isStructuredPlannerPrompt(prompt)) { - return buildStructuredGenerationPlan(prompt, config); - } - - return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); -} - -function buildExplicitRequestPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedMode: PlaygroundRequestMode | null, - requestedOperation: ModelRequestedOperation | null, -): PlaygroundRequestPlan | null { - if (requestedMode === "inline-autocomplete") { - return buildInlineAutocompletePlanFromRequest(prompt, config); - } - if (requestedMode === "selection-fast") { - if (requestedOperation && isExplicitLocalOperation(requestedOperation)) { - return buildExplicitLocalOperationPlan(prompt, config, requestedOperation); - } - const selectionFastPathPlan = buildSelectionFastPathPlan( - editor, - prompt, - config, - requestedOperation, - ); - if (selectionFastPathPlan) { - return selectionFastPathPlan; - } - throw new Error(EXPLICIT_SELECTION_FAST_REQUEST_ERROR); - } - if (requestedMode === "structured-generation") { - return buildStructuredGenerationPlan(prompt, config); - } - if (requestedMode === "document-agent") { - return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); - } - return null; -} - -function buildExplicitLocalOperationPlan( - prompt: string, - config: PlaygroundPlannerConfig, - operation: ModelRequestedOperation, -): PlaygroundRequestPlan { - return { - mode: "selection-fast", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.selectionFastPathSystemPrompt, - prompt: buildExplicitLocalOperationPrompt(prompt, operation), - useTools: false, - maxOutputTokens: config.selectionOutputTokenCap, - temperature: 0, - stopSequences: undefined, - promptContext: null, - selectedTextLength: resolveExplicitLocalOperationSourceText(operation).length, - }; -} - -export function buildExplicitLocalOperationPrompt( - prompt: string, - operation: ModelRequestedOperation, -): string { - const parsedPrompt = parseSessionExecutionPrompt(prompt); - const latestPrompt = parsedPrompt?.latestPrompt ?? prompt; - const previousPromptSection = - (parsedPrompt?.previousPrompts.length ?? 0) > 0 - ? [ - "Earlier requests in this same session:", - ...parsedPrompt!.previousPrompts.map( - (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, - ), - "", - ] - : []; - if (operation.kind === "rewrite-selection") { - const target = - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" - ? operation.target - : null; - if (!target) { - return prompt; - } - if ( - target.kind === "scoped-range" && - target.contentFormat === "markdown" - ) { - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - "Treat the latest instruction as authoritative.", - "If the instruction asks for a rewrite, replace the full target scope instead of continuing from it.", - "If the instruction removes the target content, return an empty payload wrapper.", - "Return the full replacement markdown for the selected target scope.", - "", - "Target content (rough markdown):", - target.sourceText || "(empty)", - "", - "Wrap the resulting markdown content exactly like this:", - "markdown content", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - "Selected text to replace:", - target.sourceText, - "", - "Wrap the rewritten replacement text exactly like this:", - "replacement text", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - if (operation.kind === "rewrite-block") { - const target = operation.target.kind === "block" ? operation.target : null; - if (!target) { - return prompt; - } - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - `Block type: ${target.blockType ?? "unknown"}`, - "Current block content:", - target.sourceText, - "", - "Wrap the rewritten replacement content exactly like this:", - "replacement content", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - if (operation.kind === "continue-block") { - const target = operation.target.kind === "block" ? operation.target : null; - if (!target) { - return prompt; - } - const insertionOffset = target.insertionOffset ?? target.sourceText.length; - return [ - "Instruction:", - latestPrompt, - "", - ...previousPromptSection, - `Block type: ${target.blockType ?? "unknown"}`, - "Text before cursor:", - target.sourceText.slice(0, insertionOffset), - "", - "Text after cursor:", - target.sourceText.slice(insertionOffset), - "", - "Wrap the continuation text exactly like this:", - "continuation text", - "Do not output anything before or after the wrapper.", - ].join("\n"); - } - return prompt; -} - -function buildStructuredGenerationPlan( - prompt: string, - config: PlaygroundPlannerConfig, -): PlaygroundRequestPlan { - return { - mode: "structured-generation", - modelId: config.documentModel, - contextFormat: "none", - systemPrompt: config.structuredPlannerSystemPrompt, - prompt, - useTools: false, - temperature: undefined, - stopSequences: undefined, - promptContext: null, - selectedTextLength: null, - }; -} - -function buildDocumentAgentPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedOperation?: ModelRequestedOperation | null, -): PlaygroundRequestPlan { - const promptContext = buildPromptContext(editor); - return { - mode: "document-agent", - modelId: config.documentModel, - contextFormat: "json", - systemPrompt: config.documentSystemPrompt, - prompt: buildPromptEnvelope(prompt, promptContext.json, requestedOperation), - useTools: false, - temperature: undefined, - stopSequences: undefined, - promptContext, - selectedTextLength: null, - }; -} - -export function buildPromptContext( - editor: Editor, -): PlaygroundPromptContextEnvelope { - const blocks = Array.from(editor.blocks()).map((block) => ({ - id: block.id, - type: block.type, - text: truncateText(block.textContent({ resolved: true }), 240), - childCount: block.children.length, - })); - const selection = editor.selection; - const selectedText = truncateText(editor.getSelectedText(), 600); - const activeBlockId = resolveSelectionBlockId(selection); - const activeBlockIndex = activeBlockId - ? blocks.findIndex((block) => block.id === activeBlockId) - : -1; - const nearbyBlocks = resolveNearbyBlocks(blocks, activeBlockIndex); - const activeBlock = - activeBlockIndex >= 0 ? blocks[activeBlockIndex] ?? null : blocks[0] ?? null; - const payload = { - blockCount: editor.blockCount(), - selectionType: selection?.type ?? null, - activeBlockId, - selectedText, - activeBlock, - nearbyBlocks, - blockTypes: [...new Set(blocks.map((block) => block.type))], - }; - const json = JSON.stringify(payload); - - return { - json, - jsonBytes: utf8Encoder.encode(json).byteLength, - estimatedJsonTokens: estimateTokens(json), - }; -} - -export function createPlaygroundRequestMetricsSeed( - requestPlan: PlaygroundRequestPlan, -): { - requestMode: PlaygroundRequestMode; - requestModel: string; - contextFormat: PlaygroundResolvedContextFormat; - firstToolStartMs: number | null; - firstToolResultMs: number | null; - firstTextDeltaServerMs: number | null; - totalServerMs: number | null; - toolCallCount: number; - toolExecutionMs: number; - contextBytesJson: number | null; - contextEstimatedTokensJson: number | null; -} { - return { - requestMode: requestPlan.mode, - requestModel: requestPlan.modelId, - contextFormat: requestPlan.contextFormat, - firstToolStartMs: null, - firstToolResultMs: null, - firstTextDeltaServerMs: null, - totalServerMs: null, - toolCallCount: 0, - toolExecutionMs: 0, - contextBytesJson: requestPlan.promptContext?.jsonBytes ?? null, - contextEstimatedTokensJson: - requestPlan.promptContext?.estimatedJsonTokens ?? null, - }; -} - -export function estimateTokens(value: string): number { - return Math.max(1, Math.ceil(value.length / 4)); -} - -function isStructuredPlannerPrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trimStart(); - return ( - normalizedPrompt.startsWith(STRUCTURED_PLANNER_PROMPT_PREFIX) || - normalizedPrompt.includes(`User request:\n${STRUCTURED_PLANNER_PROMPT_PREFIX}`) - ); -} - -function buildPromptEnvelope( - prompt: string, - context: string, - requestedOperation?: ModelRequestedOperation | null, -): string { - const operationEnvelope = - requestedOperation == null - ? null - : JSON.stringify({ - kind: requestedOperation.kind, - target: requestedOperation.target, - provenance: requestedOperation.provenance ?? null, - }); - return [ - "Direct document context (JSON, compact summary):", - context, - "", - ...(operationEnvelope - ? [ - "Resolved operation envelope (authoritative target and scope):", - operationEnvelope, - "", - ] - : []), - "Use this summary first. Call tools only when you need more precise or broader context.", - "When you answer with document content, return only the content to insert or apply.", - 'Do not add conversational lead-ins like "Here is", "Here\'s", or "I wrote".', - "", - "User request:", - prompt, - ].join("\n"); -} - -function buildInlineAutocompletePlan( - prompt: string, - config: PlaygroundPlannerConfig, -): PlaygroundRequestPlan | null { - if (!isInlineAutocompletePrompt(prompt)) { - return null; - } - - return { - mode: "inline-autocomplete", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.autocompleteSystemPrompt, - prompt, - maxOutputTokens: resolveAutocompleteOutputTokenCap(prompt, config), - temperature: 0, - stopSequences: undefined, - useTools: false, - promptContext: null, - selectedTextLength: null, - }; -} - -function buildInlineAutocompletePlanFromRequest( - prompt: string, - config: PlaygroundPlannerConfig, -): PlaygroundRequestPlan { - return { - mode: "inline-autocomplete", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.autocompleteSystemPrompt, - prompt, - useTools: false, - maxOutputTokens: config.autocompleteOutputTokenCap, - temperature: 0, - stopSequences: undefined, - promptContext: null, - selectedTextLength: null, - }; -} - -function resolveAutocompleteOutputTokenCap( - prompt: string, - config: PlaygroundPlannerConfig, -): number { - const targetScope = extractAutocompleteContinuationTargetScope(prompt); - if (targetScope === "continue-across-paragraphs") { - return Math.max(config.autocompleteOutputTokenCap * 8, 640); - } - if (targetScope === "finish-paragraph") { - return Math.max(config.autocompleteOutputTokenCap * 4, 256); - } - return config.autocompleteOutputTokenCap; -} - -function extractAutocompleteContinuationTargetScope( - prompt: string, -): "finish-paragraph" | "continue-across-paragraphs" | null { - const match = prompt.match(/^target_scope=(.+)$/m); - if (!match) { - return null; - } - if (match[1] === "finish-paragraph") { - return "finish-paragraph"; - } - if (match[1] === "continue-across-paragraphs") { - return "continue-across-paragraphs"; - } - return null; -} - -function buildSelectionFastPathPlan( - editor: Editor, - prompt: string, - config: PlaygroundPlannerConfig, - requestedOperation?: ModelRequestedOperation | null, -): PlaygroundRequestPlan | null { - const parsedPromptSelection = parsePinnedSelectionPrompt(prompt); - const explicitOperationSelection = - requestedOperation?.kind === "rewrite-selection" && - requestedOperation.target.kind === "selection" - ? requestedOperation.target.sourceText - : null; - const selectedText = ( - explicitOperationSelection ?? - parsedPromptSelection?.selectedText ?? - resolveLiveSelectedText(editor) - ).trim(); - if (!selectedText || selectedText.length > config.selectionSourceCharLimit) { - return null; - } - - const instruction = - parsedPromptSelection?.instruction ?? - extractSelectionInstruction(prompt, selectedText); - const promptKind = classifySelectionPrompt(instruction); - - return { - mode: "selection-fast", - modelId: config.selectionModel, - contextFormat: "none", - systemPrompt: config.selectionFastPathSystemPrompt, - prompt: buildSelectionPromptEnvelope( - instruction, - selectedText, - config.selectionStopSentinel, - ), - maxOutputTokens: resolveSelectionOutputTokenBudget( - promptKind, - selectedText, - config, - ), - temperature: resolveSelectionTemperature(promptKind), - stopSequences: [config.selectionStopSentinel], - useTools: false, - promptContext: null, - selectedTextLength: selectedText.length, - }; -} - -function isExplicitLocalOperation( - operation: ModelRequestedOperation, -): operation is ModelRequestedOperation { - return ( - operation.kind === "rewrite-selection" || - operation.kind === "rewrite-block" || - operation.kind === "continue-block" - ); -} - -function resolveExplicitLocalOperationSourceText( - operation: ModelRequestedOperation, -): string { - if ( - operation.target.kind === "selection" || - operation.target.kind === "scoped-range" || - operation.target.kind === "block" - ) { - return operation.target.sourceText; - } - return ""; -} - -function parseSessionExecutionPrompt( - prompt: string, -): { - latestPrompt: string; - previousPrompts: string[]; -} | null { - const normalizedPrompt = prompt.replace(/\r\n?/g, "\n").trim(); - const historyHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_HISTORY_HEADER); - const latestHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_LATEST_HEADER); - if ( - historyHeaderIndex < 0 || - latestHeaderIndex < 0 || - latestHeaderIndex <= historyHeaderIndex - ) { - return null; - } - - const intro = normalizedPrompt.slice(0, historyHeaderIndex).trim(); - if (!SESSION_PROMPT_INTROS.has(intro)) { - return null; - } - - const historyAndInstruction = normalizedPrompt.slice( - historyHeaderIndex + SESSION_PROMPT_HISTORY_HEADER.length, - latestHeaderIndex, - ); - const historySection = historyAndInstruction.split("\n\n")[0]?.trim() ?? ""; - const latestPrompt = normalizedPrompt - .slice(latestHeaderIndex + SESSION_PROMPT_LATEST_HEADER.length) - .trim(); - if (!historySection || !latestPrompt) { - return null; - } - - const previousPrompts = Array.from( - historySection.matchAll(/(?:^|\n)\d+\.\s([\s\S]*?)(?=(?:\n\d+\.\s)|$)/g), - ) - .map((match) => match[1]?.trim() ?? "") - .filter((item) => item.length > 0); - if (previousPrompts.length === 0) { - return null; - } - - return { - latestPrompt, - previousPrompts, - }; -} - -function resolveLiveSelectedText(editor: Editor): string { - const selection = editor.selection; - if (!selection || selection.type !== "text" || selection.isCollapsed) { - return ""; - } - return editor.getSelectedText(); -} - -function isInlineAutocompletePrompt(prompt: string): boolean { - const normalizedPrompt = prompt.trim(); - const promptLines = normalizedPrompt.split("\n"); - return ( - promptLines[0]?.startsWith("prefix=") === true && - promptLines[1] === "cursor_here=true" && - promptLines[2]?.startsWith("suffix=") === true - ); -} - -function buildSelectionPromptEnvelope( - instruction: string, - selectedText: string, - stopSentinel: string, -): string { - return [ - "Instruction:", - instruction, - "", - "Selected text:", - selectedText, - "", - `Return only the final replacement text. When finished, output ${stopSentinel}.`, - ].join("\n"); -} - -function parsePinnedSelectionPrompt( - prompt: string, -): { instruction: string; selectedText: string } | null { - const normalizedPrompt = prompt.replace(/\r\n?/g, "\n"); - const selectionMarker = - "Context summary:\nSource: selection\nSelected text:\n"; - const requestMarker = "\n\nUser request:\n"; - const selectionStart = normalizedPrompt.indexOf(selectionMarker); - if (selectionStart < 0) { - return null; - } - const requestStart = normalizedPrompt.lastIndexOf(requestMarker); - if (requestStart <= selectionStart + selectionMarker.length) { - return null; - } - const selectedText = normalizedPrompt - .slice(selectionStart + selectionMarker.length, requestStart) - .trim(); - const instruction = normalizedPrompt - .slice(requestStart + requestMarker.length) - .trim(); - if (!selectedText || !instruction) { - return null; - } - return { - instruction, - selectedText, - }; -} - -function extractSelectionInstruction(prompt: string, selectedText: string): string { - const trimmedPrompt = prompt.trim(); - const trimmedSelection = selectedText.trim(); - if (!trimmedSelection) { - return trimmedPrompt; - } - - const selectionSuffix = `\n\n${trimmedSelection}`; - if (trimmedPrompt.endsWith(selectionSuffix)) { - return trimmedPrompt.slice(0, -selectionSuffix.length).trim(); - } - - if (trimmedPrompt.endsWith(trimmedSelection)) { - return trimmedPrompt.slice(0, -trimmedSelection.length).trim(); - } - - return trimmedPrompt; -} - -function classifySelectionPrompt( - instruction: string, -): "rewrite" | "summarize" | "translate" | "expand" { - const normalizedInstruction = instruction.trim().toLowerCase(); - - if (normalizedInstruction.startsWith("summarize")) { - return "summarize"; - } - - if (normalizedInstruction.startsWith("translate")) { - return "translate"; - } - - if ( - normalizedInstruction.startsWith("expand") || - normalizedInstruction.includes("more detail") - ) { - return "expand"; - } - - if ( - normalizedInstruction.startsWith("rewrite") || - normalizedInstruction.startsWith("fix grammar") || - normalizedInstruction.startsWith("simplify") || - normalizedInstruction.startsWith("shorten") || - normalizedInstruction.startsWith("make") || - normalizedInstruction.startsWith("improve") - ) { - return "rewrite"; - } - - return "rewrite"; -} - -function resolveSelectionOutputTokenBudget( - promptKind: "rewrite" | "summarize" | "translate" | "expand", - selectedText: string, - config: PlaygroundPlannerConfig, -): number { - const selectedTokenEstimate = estimateTokens(selectedText); - - if (promptKind === "summarize") { - return Math.min( - config.selectionSummarizeOutputTokens, - Math.max(80, Math.ceil(selectedTokenEstimate * 0.6)), - ); - } - - if (promptKind === "translate") { - return Math.min( - config.selectionTranslateOutputTokens, - Math.max(120, Math.ceil(selectedTokenEstimate * 1.35)), - ); - } - - if (promptKind === "expand") { - return Math.min( - config.selectionOutputTokenCap, - Math.max( - config.selectionExpandOutputTokens, - Math.ceil(selectedTokenEstimate * 2), - ), - ); - } - - if (promptKind === "rewrite") { - return Math.min( - 220, - Math.max(72, Math.ceil(selectedTokenEstimate * 1.1)), - ); - } - - return Math.min( - config.selectionOutputTokenCap, - Math.max( - config.selectionDefaultOutputTokens, - selectedTokenEstimate, - ), - ); -} - -function resolveSelectionTemperature( - promptKind: "rewrite" | "summarize" | "translate" | "expand", -): number { - if (promptKind === "expand") { - return 0.3; - } - - if (promptKind === "translate") { - return 0.2; - } - - return 0; -} - -function resolveNearbyBlocks( - blocks: Array<{ id: string; type: string; text: string; childCount: number }>, - activeBlockIndex: number, -) { - if (blocks.length === 0) { - return []; - } - - if (activeBlockIndex < 0) { - return blocks.slice(0, 5); - } - - const startIndex = Math.max(0, activeBlockIndex - 2); - const endIndex = Math.min(blocks.length, activeBlockIndex + 3); - return blocks.slice(startIndex, endIndex); -} - -function resolveSelectionBlockId( - selection: SelectionState, -): string | null { - if (!selection) { - return null; - } - - if (selection.type === "text" && "anchor" in selection) { - return selection.anchor.blockId; - } - - if (selection.type === "cell") { - return selection.blockId; - } - - if (selection.type === "block") { - return selection.blockIds[0] ?? null; - } - - return null; -} - -function truncateText(value: string, limit: number): string { - if (value.length <= limit) { - return value; - } - - return `${value.slice(0, limit)}...`; -} +export { buildPlaygroundRequestPlan, buildExplicitLocalOperationPrompt, buildPromptContext } from "./playgroundPlannerParts/playgroundPlannerPart1"; +export type { PlaygroundPromptContextEnvelope, PlaygroundRequestMode, PlaygroundResolvedContextFormat, PlaygroundRequestPlan, PlaygroundPlannerConfig } from "./playgroundPlannerParts/playgroundPlannerPart1"; +export { createPlaygroundRequestMetricsSeed, estimateTokens } from "./playgroundPlannerParts/playgroundPlannerPart2"; diff --git a/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts new file mode 100644 index 0000000..18cb8d3 --- /dev/null +++ b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart1.ts @@ -0,0 +1,341 @@ +// @ts-nocheck +import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; +import { parseStructuredIntentRequestPrompt } from "../structuredIntent"; +import { createPlaygroundRequestMetricsSeed, estimateTokens, isStructuredPlannerPrompt, buildPromptEnvelope, buildInlineAutocompletePlan, buildInlineAutocompletePlanFromRequest, resolveAutocompleteOutputTokenCap, extractAutocompleteContinuationTargetScope, buildSelectionFastPathPlan, isExplicitLocalOperation, resolveExplicitLocalOperationSourceText, parseSessionExecutionPrompt, resolveLiveSelectedText, isInlineAutocompletePrompt, buildSelectionPromptEnvelope, parsePinnedSelectionPrompt, extractSelectionInstruction } from "./playgroundPlannerPart2"; +import { classifySelectionPrompt, resolveSelectionOutputTokenBudget, resolveSelectionTemperature, resolveNearbyBlocks, resolveSelectionBlockId, truncateText } from "./playgroundPlannerPart3"; + +export interface PlaygroundPromptContextEnvelope { + json: string; + jsonBytes: number; + estimatedJsonTokens: number; +} + +export type PlaygroundRequestMode = + | "document-agent" + | "structured-generation" + | "selection-fast" + | "inline-autocomplete"; + +export type PlaygroundResolvedContextFormat = "json" | "none"; + +export interface PlaygroundRequestPlan { + mode: PlaygroundRequestMode; + modelId: string; + contextFormat: PlaygroundResolvedContextFormat; + systemPrompt: string; + prompt: string; + maxOutputTokens?: number; + temperature?: number; + stopSequences?: string[]; + useTools: boolean; + promptContext: PlaygroundPromptContextEnvelope | null; + selectedTextLength: number | null; +} + +export interface PlaygroundPlannerConfig { + documentModel: string; + selectionModel: string; + documentSystemPrompt: string; + structuredPlannerSystemPrompt: string; + selectionFastPathSystemPrompt: string; + autocompleteSystemPrompt: string; + selectionSourceCharLimit: number; + selectionStopSentinel: string; + selectionOutputTokenCap: number; + autocompleteOutputTokenCap: number; + selectionDefaultOutputTokens: number; + selectionExpandOutputTokens: number; + selectionSummarizeOutputTokens: number; + selectionTranslateOutputTokens: number; +} + +export const NEARBY_BLOCK_RADIUS = 2; + +export const STRUCTURED_PLANNER_PROMPT_PREFIX = + "Produce a structured Pen document mutation plan."; + +export const EXPLICIT_SELECTION_FAST_REQUEST_ERROR = + "Explicit selection-fast requests require a live or pinned text selection."; + +export const SESSION_PROMPT_HISTORY_HEADER = "Earlier user requests in this same session:\n"; + +export const SESSION_PROMPT_LATEST_HEADER = "\nLatest request:\n"; + +export const SESSION_PROMPT_INTROS = new Set([ + "You are continuing an existing inline editor edit session.", + "You are continuing an existing editor chat session.", +]); + +export const utf8Encoder = new TextEncoder(); + +export function buildPlaygroundRequestPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedMode: PlaygroundRequestMode | null = null, + requestedOperation: ModelRequestedOperation | null = null, +): PlaygroundRequestPlan { + const explicitPlan = buildExplicitRequestPlan( + editor, + prompt, + config, + requestedMode, + requestedOperation, + ); + if (explicitPlan) { + return explicitPlan; + } + if (parseStructuredIntentRequestPrompt(prompt)) { + return buildStructuredGenerationPlan(prompt, config); + } + + const inlineAutocompletePlan = buildInlineAutocompletePlan(prompt, config); + if (inlineAutocompletePlan) { + return inlineAutocompletePlan; + } + + const selectionPlan = buildSelectionFastPathPlan(editor, prompt, config); + if (selectionPlan) { + return selectionPlan; + } + + if (isStructuredPlannerPrompt(prompt)) { + return buildStructuredGenerationPlan(prompt, config); + } + + return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); +} + +export function buildExplicitRequestPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedMode: PlaygroundRequestMode | null, + requestedOperation: ModelRequestedOperation | null, +): PlaygroundRequestPlan | null { + if (requestedMode === "inline-autocomplete") { + return buildInlineAutocompletePlanFromRequest(prompt, config); + } + if (requestedMode === "selection-fast") { + if (requestedOperation && isExplicitLocalOperation(requestedOperation)) { + return buildExplicitLocalOperationPlan(prompt, config, requestedOperation); + } + const selectionFastPathPlan = buildSelectionFastPathPlan( + editor, + prompt, + config, + requestedOperation, + ); + if (selectionFastPathPlan) { + return selectionFastPathPlan; + } + throw new Error(EXPLICIT_SELECTION_FAST_REQUEST_ERROR); + } + if (requestedMode === "structured-generation") { + return buildStructuredGenerationPlan(prompt, config); + } + if (requestedMode === "document-agent") { + return buildDocumentAgentPlan(editor, prompt, config, requestedOperation); + } + return null; +} + +export function buildExplicitLocalOperationPlan( + prompt: string, + config: PlaygroundPlannerConfig, + operation: ModelRequestedOperation, +): PlaygroundRequestPlan { + return { + mode: "selection-fast", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.selectionFastPathSystemPrompt, + prompt: buildExplicitLocalOperationPrompt(prompt, operation), + useTools: false, + maxOutputTokens: config.selectionOutputTokenCap, + temperature: 0, + stopSequences: undefined, + promptContext: null, + selectedTextLength: resolveExplicitLocalOperationSourceText(operation).length, + }; +} + +export function buildExplicitLocalOperationPrompt( + prompt: string, + operation: ModelRequestedOperation, +): string { + const parsedPrompt = parseSessionExecutionPrompt(prompt); + const latestPrompt = parsedPrompt?.latestPrompt ?? prompt; + const previousPromptSection = + (parsedPrompt?.previousPrompts.length ?? 0) > 0 + ? [ + "Earlier requests in this same session:", + ...parsedPrompt!.previousPrompts.map( + (previousPrompt, index) => `${index + 1}. ${previousPrompt}`, + ), + "", + ] + : []; + if (operation.kind === "rewrite-selection") { + const target = + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" + ? operation.target + : null; + if (!target) { + return prompt; + } + if ( + target.kind === "scoped-range" && + target.contentFormat === "markdown" + ) { + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + "Treat the latest instruction as authoritative.", + "If the instruction asks for a rewrite, replace the full target scope instead of continuing from it.", + "If the instruction removes the target content, return an empty payload wrapper.", + "Return the full replacement markdown for the selected target scope.", + "", + "Target content (rough markdown):", + target.sourceText || "(empty)", + "", + "Wrap the resulting markdown content exactly like this:", + "markdown content", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + "Selected text to replace:", + target.sourceText, + "", + "Wrap the rewritten replacement text exactly like this:", + "replacement text", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + if (operation.kind === "rewrite-block") { + const target = operation.target.kind === "block" ? operation.target : null; + if (!target) { + return prompt; + } + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + `Block type: ${target.blockType ?? "unknown"}`, + "Current block content:", + target.sourceText, + "", + "Wrap the rewritten replacement content exactly like this:", + "replacement content", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + if (operation.kind === "continue-block") { + const target = operation.target.kind === "block" ? operation.target : null; + if (!target) { + return prompt; + } + const insertionOffset = target.insertionOffset ?? target.sourceText.length; + return [ + "Instruction:", + latestPrompt, + "", + ...previousPromptSection, + `Block type: ${target.blockType ?? "unknown"}`, + "Text before cursor:", + target.sourceText.slice(0, insertionOffset), + "", + "Text after cursor:", + target.sourceText.slice(insertionOffset), + "", + "Wrap the continuation text exactly like this:", + "continuation text", + "Do not output anything before or after the wrapper.", + ].join("\n"); + } + return prompt; +} + +export function buildStructuredGenerationPlan( + prompt: string, + config: PlaygroundPlannerConfig, +): PlaygroundRequestPlan { + return { + mode: "structured-generation", + modelId: config.documentModel, + contextFormat: "none", + systemPrompt: config.structuredPlannerSystemPrompt, + prompt, + useTools: false, + temperature: undefined, + stopSequences: undefined, + promptContext: null, + selectedTextLength: null, + }; +} + +export function buildDocumentAgentPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedOperation?: ModelRequestedOperation | null, +): PlaygroundRequestPlan { + const promptContext = buildPromptContext(editor); + return { + mode: "document-agent", + modelId: config.documentModel, + contextFormat: "json", + systemPrompt: config.documentSystemPrompt, + prompt: buildPromptEnvelope(prompt, promptContext.json, requestedOperation), + useTools: false, + temperature: undefined, + stopSequences: undefined, + promptContext, + selectedTextLength: null, + }; +} + +export function buildPromptContext( + editor: Editor, +): PlaygroundPromptContextEnvelope { + const blocks = Array.from(editor.blocks()).map((block) => ({ + id: block.id, + type: block.type, + text: truncateText(block.textContent({ resolved: true }), 240), + childCount: block.children.length, + })); + const selection = editor.selection; + const selectedText = truncateText(editor.getSelectedText(), 600); + const activeBlockId = resolveSelectionBlockId(selection); + const activeBlockIndex = activeBlockId + ? blocks.findIndex((block) => block.id === activeBlockId) + : -1; + const nearbyBlocks = resolveNearbyBlocks(blocks, activeBlockIndex); + const activeBlock = + activeBlockIndex >= 0 ? blocks[activeBlockIndex] ?? null : blocks[0] ?? null; + const payload = { + blockCount: editor.blockCount(), + selectionType: selection?.type ?? null, + activeBlockId, + selectedText, + activeBlock, + nearbyBlocks, + blockTypes: [...new Set(blocks.map((block) => block.type))], + }; + const json = JSON.stringify(payload); + + return { + json, + jsonBytes: utf8Encoder.encode(json).byteLength, + estimatedJsonTokens: estimateTokens(json), + }; +} diff --git a/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts new file mode 100644 index 0000000..ef9e928 --- /dev/null +++ b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart2.ts @@ -0,0 +1,358 @@ +// @ts-nocheck +import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; +import { parseStructuredIntentRequestPrompt } from "../structuredIntent"; +import { NEARBY_BLOCK_RADIUS, STRUCTURED_PLANNER_PROMPT_PREFIX, EXPLICIT_SELECTION_FAST_REQUEST_ERROR, SESSION_PROMPT_HISTORY_HEADER, SESSION_PROMPT_LATEST_HEADER, SESSION_PROMPT_INTROS, utf8Encoder, buildPlaygroundRequestPlan, buildExplicitRequestPlan, buildExplicitLocalOperationPlan, buildExplicitLocalOperationPrompt, buildStructuredGenerationPlan, buildDocumentAgentPlan, buildPromptContext } from "./playgroundPlannerPart1"; +import type { PlaygroundPromptContextEnvelope, PlaygroundRequestMode, PlaygroundResolvedContextFormat, PlaygroundRequestPlan, PlaygroundPlannerConfig } from "./playgroundPlannerPart1"; +import { classifySelectionPrompt, resolveSelectionOutputTokenBudget, resolveSelectionTemperature, resolveNearbyBlocks, resolveSelectionBlockId, truncateText } from "./playgroundPlannerPart3"; + +export function createPlaygroundRequestMetricsSeed( + requestPlan: PlaygroundRequestPlan, +): { + requestMode: PlaygroundRequestMode; + requestModel: string; + contextFormat: PlaygroundResolvedContextFormat; + firstToolStartMs: number | null; + firstToolResultMs: number | null; + firstTextDeltaServerMs: number | null; + totalServerMs: number | null; + toolCallCount: number; + toolExecutionMs: number; + contextBytesJson: number | null; + contextEstimatedTokensJson: number | null; +} { + return { + requestMode: requestPlan.mode, + requestModel: requestPlan.modelId, + contextFormat: requestPlan.contextFormat, + firstToolStartMs: null, + firstToolResultMs: null, + firstTextDeltaServerMs: null, + totalServerMs: null, + toolCallCount: 0, + toolExecutionMs: 0, + contextBytesJson: requestPlan.promptContext?.jsonBytes ?? null, + contextEstimatedTokensJson: + requestPlan.promptContext?.estimatedJsonTokens ?? null, + }; +} + +export function estimateTokens(value: string): number { + return Math.max(1, Math.ceil(value.length / 4)); +} + +export function isStructuredPlannerPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trimStart(); + return ( + normalizedPrompt.startsWith(STRUCTURED_PLANNER_PROMPT_PREFIX) || + normalizedPrompt.includes(`User request:\n${STRUCTURED_PLANNER_PROMPT_PREFIX}`) + ); +} + +export function buildPromptEnvelope( + prompt: string, + context: string, + requestedOperation?: ModelRequestedOperation | null, +): string { + const operationEnvelope = + requestedOperation == null + ? null + : JSON.stringify({ + kind: requestedOperation.kind, + target: requestedOperation.target, + provenance: requestedOperation.provenance ?? null, + }); + return [ + "Direct document context (JSON, compact summary):", + context, + "", + ...(operationEnvelope + ? [ + "Resolved operation envelope (authoritative target and scope):", + operationEnvelope, + "", + ] + : []), + "Use this summary first. Call tools only when you need more precise or broader context.", + "When you answer with document content, return only the content to insert or apply.", + 'Do not add conversational lead-ins like "Here is", "Here\'s", or "I wrote".', + "", + "User request:", + prompt, + ].join("\n"); +} + +export function buildInlineAutocompletePlan( + prompt: string, + config: PlaygroundPlannerConfig, +): PlaygroundRequestPlan | null { + if (!isInlineAutocompletePrompt(prompt)) { + return null; + } + + return { + mode: "inline-autocomplete", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.autocompleteSystemPrompt, + prompt, + maxOutputTokens: resolveAutocompleteOutputTokenCap(prompt, config), + temperature: 0, + stopSequences: undefined, + useTools: false, + promptContext: null, + selectedTextLength: null, + }; +} + +export function buildInlineAutocompletePlanFromRequest( + prompt: string, + config: PlaygroundPlannerConfig, +): PlaygroundRequestPlan { + return { + mode: "inline-autocomplete", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.autocompleteSystemPrompt, + prompt, + useTools: false, + maxOutputTokens: config.autocompleteOutputTokenCap, + temperature: 0, + stopSequences: undefined, + promptContext: null, + selectedTextLength: null, + }; +} + +export function resolveAutocompleteOutputTokenCap( + prompt: string, + config: PlaygroundPlannerConfig, +): number { + const targetScope = extractAutocompleteContinuationTargetScope(prompt); + if (targetScope === "continue-across-paragraphs") { + return Math.max(config.autocompleteOutputTokenCap * 8, 640); + } + if (targetScope === "finish-paragraph") { + return Math.max(config.autocompleteOutputTokenCap * 4, 256); + } + return config.autocompleteOutputTokenCap; +} + +export function extractAutocompleteContinuationTargetScope( + prompt: string, +): "finish-paragraph" | "continue-across-paragraphs" | null { + const match = prompt.match(/^target_scope=(.+)$/m); + if (!match) { + return null; + } + if (match[1] === "finish-paragraph") { + return "finish-paragraph"; + } + if (match[1] === "continue-across-paragraphs") { + return "continue-across-paragraphs"; + } + return null; +} + +export function buildSelectionFastPathPlan( + editor: Editor, + prompt: string, + config: PlaygroundPlannerConfig, + requestedOperation?: ModelRequestedOperation | null, +): PlaygroundRequestPlan | null { + const parsedPromptSelection = parsePinnedSelectionPrompt(prompt); + const explicitOperationSelection = + requestedOperation?.kind === "rewrite-selection" && + requestedOperation.target.kind === "selection" + ? requestedOperation.target.sourceText + : null; + const selectedText = ( + explicitOperationSelection ?? + parsedPromptSelection?.selectedText ?? + resolveLiveSelectedText(editor) + ).trim(); + if (!selectedText || selectedText.length > config.selectionSourceCharLimit) { + return null; + } + + const instruction = + parsedPromptSelection?.instruction ?? + extractSelectionInstruction(prompt, selectedText); + const promptKind = classifySelectionPrompt(instruction); + + return { + mode: "selection-fast", + modelId: config.selectionModel, + contextFormat: "none", + systemPrompt: config.selectionFastPathSystemPrompt, + prompt: buildSelectionPromptEnvelope( + instruction, + selectedText, + config.selectionStopSentinel, + ), + maxOutputTokens: resolveSelectionOutputTokenBudget( + promptKind, + selectedText, + config, + ), + temperature: resolveSelectionTemperature(promptKind), + stopSequences: [config.selectionStopSentinel], + useTools: false, + promptContext: null, + selectedTextLength: selectedText.length, + }; +} + +export function isExplicitLocalOperation( + operation: ModelRequestedOperation, +): operation is ModelRequestedOperation { + return ( + operation.kind === "rewrite-selection" || + operation.kind === "rewrite-block" || + operation.kind === "continue-block" + ); +} + +export function resolveExplicitLocalOperationSourceText( + operation: ModelRequestedOperation, +): string { + if ( + operation.target.kind === "selection" || + operation.target.kind === "scoped-range" || + operation.target.kind === "block" + ) { + return operation.target.sourceText; + } + return ""; +} + +export function parseSessionExecutionPrompt( + prompt: string, +): { + latestPrompt: string; + previousPrompts: string[]; +} | null { + const normalizedPrompt = prompt.replace(/\r\n?/g, "\n").trim(); + const historyHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_HISTORY_HEADER); + const latestHeaderIndex = normalizedPrompt.indexOf(SESSION_PROMPT_LATEST_HEADER); + if ( + historyHeaderIndex < 0 || + latestHeaderIndex < 0 || + latestHeaderIndex <= historyHeaderIndex + ) { + return null; + } + + const intro = normalizedPrompt.slice(0, historyHeaderIndex).trim(); + if (!SESSION_PROMPT_INTROS.has(intro)) { + return null; + } + + const historyAndInstruction = normalizedPrompt.slice( + historyHeaderIndex + SESSION_PROMPT_HISTORY_HEADER.length, + latestHeaderIndex, + ); + const historySection = historyAndInstruction.split("\n\n")[0]?.trim() ?? ""; + const latestPrompt = normalizedPrompt + .slice(latestHeaderIndex + SESSION_PROMPT_LATEST_HEADER.length) + .trim(); + if (!historySection || !latestPrompt) { + return null; + } + + const previousPrompts = Array.from( + historySection.matchAll(/(?:^|\n)\d+\.\s([\s\S]*?)(?=(?:\n\d+\.\s)|$)/g), + ) + .map((match) => match[1]?.trim() ?? "") + .filter((item) => item.length > 0); + if (previousPrompts.length === 0) { + return null; + } + + return { + latestPrompt, + previousPrompts, + }; +} + +export function resolveLiveSelectedText(editor: Editor): string { + const selection = editor.selection; + if (!selection || selection.type !== "text" || selection.isCollapsed) { + return ""; + } + return editor.getSelectedText(); +} + +export function isInlineAutocompletePrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim(); + const promptLines = normalizedPrompt.split("\n"); + return ( + promptLines[0]?.startsWith("prefix=") === true && + promptLines[1] === "cursor_here=true" && + promptLines[2]?.startsWith("suffix=") === true + ); +} + +export function buildSelectionPromptEnvelope( + instruction: string, + selectedText: string, + stopSentinel: string, +): string { + return [ + "Instruction:", + instruction, + "", + "Selected text:", + selectedText, + "", + `Return only the final replacement text. When finished, output ${stopSentinel}.`, + ].join("\n"); +} + +export function parsePinnedSelectionPrompt( + prompt: string, +): { instruction: string; selectedText: string } | null { + const normalizedPrompt = prompt.replace(/\r\n?/g, "\n"); + const selectionMarker = + "Context summary:\nSource: selection\nSelected text:\n"; + const requestMarker = "\n\nUser request:\n"; + const selectionStart = normalizedPrompt.indexOf(selectionMarker); + if (selectionStart < 0) { + return null; + } + const requestStart = normalizedPrompt.lastIndexOf(requestMarker); + if (requestStart <= selectionStart + selectionMarker.length) { + return null; + } + const selectedText = normalizedPrompt + .slice(selectionStart + selectionMarker.length, requestStart) + .trim(); + const instruction = normalizedPrompt + .slice(requestStart + requestMarker.length) + .trim(); + if (!selectedText || !instruction) { + return null; + } + return { + instruction, + selectedText, + }; +} + +export function extractSelectionInstruction(prompt: string, selectedText: string): string { + const trimmedPrompt = prompt.trim(); + const trimmedSelection = selectedText.trim(); + if (!trimmedSelection) { + return trimmedPrompt; + } + + const selectionSuffix = `\n\n${trimmedSelection}`; + if (trimmedPrompt.endsWith(selectionSuffix)) { + return trimmedPrompt.slice(0, -selectionSuffix.length).trim(); + } + + if (trimmedPrompt.endsWith(trimmedSelection)) { + return trimmedPrompt.slice(0, -trimmedSelection.length).trim(); + } + + return trimmedPrompt; +} diff --git a/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts new file mode 100644 index 0000000..2ea9a11 --- /dev/null +++ b/packages/extensions/ai/src/runtime/playgroundPlannerParts/playgroundPlannerPart3.ts @@ -0,0 +1,148 @@ +// @ts-nocheck +import type { Editor, ModelRequestedOperation, SelectionState } from "@pen/types"; +import { parseStructuredIntentRequestPrompt } from "../structuredIntent"; +import { NEARBY_BLOCK_RADIUS, STRUCTURED_PLANNER_PROMPT_PREFIX, EXPLICIT_SELECTION_FAST_REQUEST_ERROR, SESSION_PROMPT_HISTORY_HEADER, SESSION_PROMPT_LATEST_HEADER, SESSION_PROMPT_INTROS, utf8Encoder, buildPlaygroundRequestPlan, buildExplicitRequestPlan, buildExplicitLocalOperationPlan, buildExplicitLocalOperationPrompt, buildStructuredGenerationPlan, buildDocumentAgentPlan, buildPromptContext } from "./playgroundPlannerPart1"; +import type { PlaygroundPromptContextEnvelope, PlaygroundRequestMode, PlaygroundResolvedContextFormat, PlaygroundRequestPlan, PlaygroundPlannerConfig } from "./playgroundPlannerPart1"; +import { createPlaygroundRequestMetricsSeed, estimateTokens, isStructuredPlannerPrompt, buildPromptEnvelope, buildInlineAutocompletePlan, buildInlineAutocompletePlanFromRequest, resolveAutocompleteOutputTokenCap, extractAutocompleteContinuationTargetScope, buildSelectionFastPathPlan, isExplicitLocalOperation, resolveExplicitLocalOperationSourceText, parseSessionExecutionPrompt, resolveLiveSelectedText, isInlineAutocompletePrompt, buildSelectionPromptEnvelope, parsePinnedSelectionPrompt, extractSelectionInstruction } from "./playgroundPlannerPart2"; + +export function classifySelectionPrompt( + instruction: string, +): "rewrite" | "summarize" | "translate" | "expand" { + const normalizedInstruction = instruction.trim().toLowerCase(); + + if (normalizedInstruction.startsWith("summarize")) { + return "summarize"; + } + + if (normalizedInstruction.startsWith("translate")) { + return "translate"; + } + + if ( + normalizedInstruction.startsWith("expand") || + normalizedInstruction.includes("more detail") + ) { + return "expand"; + } + + if ( + normalizedInstruction.startsWith("rewrite") || + normalizedInstruction.startsWith("fix grammar") || + normalizedInstruction.startsWith("simplify") || + normalizedInstruction.startsWith("shorten") || + normalizedInstruction.startsWith("make") || + normalizedInstruction.startsWith("improve") + ) { + return "rewrite"; + } + + return "rewrite"; +} + +export function resolveSelectionOutputTokenBudget( + promptKind: "rewrite" | "summarize" | "translate" | "expand", + selectedText: string, + config: PlaygroundPlannerConfig, +): number { + const selectedTokenEstimate = estimateTokens(selectedText); + + if (promptKind === "summarize") { + return Math.min( + config.selectionSummarizeOutputTokens, + Math.max(80, Math.ceil(selectedTokenEstimate * 0.6)), + ); + } + + if (promptKind === "translate") { + return Math.min( + config.selectionTranslateOutputTokens, + Math.max(120, Math.ceil(selectedTokenEstimate * 1.35)), + ); + } + + if (promptKind === "expand") { + return Math.min( + config.selectionOutputTokenCap, + Math.max( + config.selectionExpandOutputTokens, + Math.ceil(selectedTokenEstimate * 2), + ), + ); + } + + if (promptKind === "rewrite") { + return Math.min( + 220, + Math.max(72, Math.ceil(selectedTokenEstimate * 1.1)), + ); + } + + return Math.min( + config.selectionOutputTokenCap, + Math.max( + config.selectionDefaultOutputTokens, + selectedTokenEstimate, + ), + ); +} + +export function resolveSelectionTemperature( + promptKind: "rewrite" | "summarize" | "translate" | "expand", +): number { + if (promptKind === "expand") { + return 0.3; + } + + if (promptKind === "translate") { + return 0.2; + } + + return 0; +} + +export function resolveNearbyBlocks( + blocks: Array<{ id: string; type: string; text: string; childCount: number }>, + activeBlockIndex: number, +) { + if (blocks.length === 0) { + return []; + } + + if (activeBlockIndex < 0) { + return blocks.slice(0, 5); + } + + const startIndex = Math.max(0, activeBlockIndex - 2); + const endIndex = Math.min(blocks.length, activeBlockIndex + 3); + return blocks.slice(startIndex, endIndex); +} + +export function resolveSelectionBlockId( + selection: SelectionState, +): string | null { + if (!selection) { + return null; + } + + if (selection.type === "text" && "anchor" in selection) { + return selection.anchor.blockId; + } + + if (selection.type === "cell") { + return selection.blockId; + } + + if (selection.type === "block") { + return selection.blockIds[0] ?? null; + } + + return null; +} + +export function truncateText(value: string, limit: number): string { + if (value.length <= limit) { + return value; + } + + return `${value.slice(0, limit)}...`; +} diff --git a/packages/extensions/ai/src/runtime/promptTargeting.ts b/packages/extensions/ai/src/runtime/promptTargeting.ts new file mode 100644 index 0000000..d03a8cb --- /dev/null +++ b/packages/extensions/ai/src/runtime/promptTargeting.ts @@ -0,0 +1,44 @@ +export function isClearDocumentPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + return ( + /\b(remove|delete|clear|erase|wipe)\b/.test(normalizedPrompt) && + /\b(all|entire|whole|everything)\b/.test(normalizedPrompt) && + /\b(document|content|contents|text|story|page)\b/.test(normalizedPrompt) + ); +} + +export function isWholeDocumentRewritePrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + return ( + /\b(rewrite|redo|revise|rework|replace)\s+(?:the|this|my)?\s*(?:entire|whole|full|all)?\s*(?:document|content|contents|text|story|page)\b/.test( + normalizedPrompt, + ) || /\bmake (?:it|this) about\b/.test(normalizedPrompt) + ); +} + +export function isDocumentResetPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + return /\b(start(?:ing)?\s+(?:over|again|from scratch)|begin\s+again|from scratch|restart)\b/.test( + normalizedPrompt, + ); +} + +export function isDocumentFollowUpEditPrompt(prompt: string): boolean { + const normalizedPrompt = prompt.trim().toLowerCase(); + if ( + /\b(continue|append|add|insert|another|more|next)\b/.test( + normalizedPrompt, + ) + ) { + return false; + } + return ( + /\b(change|update|adjust|edit|fix|improve|polish|revise|rework|rename|retitle|make)\b/.test( + normalizedPrompt, + ) && + (/\b(title|heading|story|document|content|contents|text|tone|voice|ending|opening|intro|introduction|theme)\b/.test( + normalizedPrompt, + ) || + /\bmake (?:it|this)\b/.test(normalizedPrompt)) + ); +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifacts.ts b/packages/extensions/ai/src/runtime/reviewArtifacts.ts index fea14be..fb0d617 100644 --- a/packages/extensions/ai/src/runtime/reviewArtifacts.ts +++ b/packages/extensions/ai/src/runtime/reviewArtifacts.ts @@ -1,1202 +1,2 @@ -import type { Editor } from "@pen/types"; -import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; -import type { DocumentMutationPlan } from "./planTypes"; -import type { AITargetKind } from "./contracts"; - -export interface StructuralReviewItem { - id: string; - targetKind: AITargetKind | "bundle"; - planKind: DocumentMutationPlan["kind"]; - changeKind: "added" | "removed" | "updated" | "moved"; - section: "content" | "block" | "row" | "cell" | "schema" | "view"; - groupId: string; - groupLabel: string; - label: string; - summary: string; - detail?: string; - preview?: string; - before?: string; - after?: string; - comparisonRows?: StructuralReviewComparisonRow[]; - bundlePath: number[]; - stepIndex: number | null; -} - -export interface StructuralReviewComparisonRow { - label: string; - before?: string; - after?: string; - changeKind: "added" | "removed" | "updated"; - section: "schema" | "view"; -} - -interface StructuralReviewBuildContext { - virtualBlocks: Map; -} - -interface DatabaseReviewSnapshot { - columns: TableColumnSchema[]; - rows: Array<{ - id: string; - values: Record; - }>; - views: DatabaseViewState[]; - primaryViewId: string | null; -} - -export interface StructuredPreviewDatabaseState { - columns: TableColumnSchema[]; - rows: Array<{ - id: string; - values: Record; - }>; - views: DatabaseViewState[]; - primaryViewId: string | null; -} - -export interface StructuredPreviewTargetState { - blockId: string; - targetKind: "database"; - database: StructuredPreviewDatabaseState; -} - -type VirtualReviewBlock = { - type: "database"; - database: DatabaseReviewSnapshot; -}; - -export function buildStructuralReviewItems( - editor: Editor, - plan: DocumentMutationPlan, -): StructuralReviewItem[] { - return buildStructuralPreviewArtifacts(editor, plan).reviewItems; -} - -export function buildStructuredPreviewTargets( - editor: Editor, - plan: DocumentMutationPlan, -): StructuredPreviewTargetState[] { - return buildStructuralPreviewArtifacts(editor, plan).targets; -} - -function buildStructuralPreviewArtifacts( - editor: Editor, - plan: DocumentMutationPlan, -): { - reviewItems: StructuralReviewItem[]; - targets: StructuredPreviewTargetState[]; -} { - const context: StructuralReviewBuildContext = { - virtualBlocks: new Map(), - }; - const reviewItems = buildReviewItemsForPlan(editor, plan, [], context); - return { - reviewItems, - targets: serializeStructuredPreviewTargets(context.virtualBlocks), - }; -} - -export function selectStructuralReviewItemPlan( - plan: DocumentMutationPlan, - item: StructuralReviewItem, -): DocumentMutationPlan | null { - return selectPlanAtPath(plan, item.bundlePath, item.stepIndex); -} - -export function removeStructuralReviewItemPlan( - plan: DocumentMutationPlan, - item: StructuralReviewItem, -): DocumentMutationPlan | null { - return removePlanAtPath(plan, item.bundlePath, item.stepIndex); -} - -function buildReviewItemsForPlan( - editor: Editor, - plan: DocumentMutationPlan, - bundlePath: number[], - context: StructuralReviewBuildContext, -): StructuralReviewItem[] { - switch (plan.kind) { - case "text_edit": - return [ - createReviewItem(bundlePath, plan.kind, "text", { - changeKind: describeTextEditChangeKind(plan.operation), - section: "content", - groupId: `block:${plan.target.blockId}`, - groupLabel: `Block "${plan.target.blockId}"`, - label: describeTextEditLabel(plan.operation), - summary: "Updates the selected text range.", - preview: plan.text, - before: readTextEditBefore(editor, plan), - after: plan.text, - }), - ]; - case "flow_patch": - return plan.edits.map((edit, index) => - createReviewItem(bundlePath, plan.kind, "text", { - changeKind: - edit.operation === "append_text" || edit.operation === "insert_after" || edit.operation === "insert_before" - ? "added" - : edit.operation === "delete_blocks" - ? "removed" - : "updated", - section: "content", - groupId: - edit.locator.blockId != null - ? `block:${edit.locator.blockId}` - : `span:${plan.targetSpanId ?? "flow-patch"}`, - groupLabel: - edit.locator.blockId != null - ? `Block "${edit.locator.blockId}"` - : `Span "${plan.targetSpanId ?? "flow-patch"}"`, - label: `Flow patch: ${edit.operation}`, - summary: plan.instructions, - detail: edit.locator.expectedBlockType, - preview: edit.text ?? edit.markdown, - before: - edit.locator.blockId != null - ? editor.getBlock(edit.locator.blockId)?.textContent() ?? undefined - : undefined, - after: edit.text ?? edit.markdown, - stepIndex: index, - }), - ); - case "block_insert": - registerInsertedReviewBlock(context, plan); - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "added", - section: "block", - groupId: "blocks", - groupLabel: "Blocks", - label: "Insert block", - summary: `Adds a new ${plan.blockType} block.`, - detail: plan.blockType, - preview: plan.initialText, - before: "(new block)", - after: describeInsertedBlockAfter(plan), - }), - ]; - case "block_update": - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "updated", - section: "block", - groupId: `block:${plan.blockId}`, - groupLabel: `Block "${plan.blockId}"`, - label: "Update block", - summary: "Updates block properties.", - detail: `${Object.keys(plan.props).length} prop changes`, - before: readBlockPropsPreview(editor, plan.blockId), - after: stringifyReviewValue(plan.props), - }), - ]; - case "block_move": - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "moved", - section: "block", - groupId: `block:${plan.blockId}`, - groupLabel: `Block "${plan.blockId}"`, - label: "Move block", - summary: "Moves this block to a new position.", - }), - ]; - case "block_convert": - return [ - createReviewItem(bundlePath, plan.kind, "block", { - changeKind: "updated", - section: "block", - groupId: `block:${plan.blockId}`, - groupLabel: `Block "${plan.blockId}"`, - label: "Convert block", - summary: `Converts this block to ${plan.newType}.`, - detail: plan.newType, - before: readBlockTypePreview(editor, plan.blockId), - after: plan.newType, - }), - ]; - case "database_edit": - return buildDatabaseReviewItems( - editor, - plan, - bundlePath, - context, - ); - case "review_bundle": - return plan.plans.flatMap((nestedPlan, index) => - buildReviewItemsForPlan(editor, nestedPlan, [...bundlePath, index], context), - ); - } -} - -function serializeStructuredPreviewTargets( - virtualBlocks: Map, -): StructuredPreviewTargetState[] { - return [...virtualBlocks.entries()].map(([blockId, virtualBlock]) => { - return { - blockId, - targetKind: "database", - database: cloneDatabaseReviewSnapshot(virtualBlock.database), - }; - }); -} - -function buildDatabaseReviewItems( - editor: Editor, - plan: Extract, - bundlePath: number[], - context: StructuralReviewBuildContext, -): StructuralReviewItem[] { - const snapshot = getDatabaseReviewSnapshot(editor, plan.blockId, context); - const items: StructuralReviewItem[] = []; - - for (let index = 0; index < plan.steps.length; index += 1) { - const step = plan.steps[index]!; - const beforeSnapshot = snapshot ? cloneDatabaseReviewSnapshot(snapshot) : null; - items.push( - createReviewItem(bundlePath, plan.kind, "database", { - changeKind: describeDatabaseStepChangeKind(step.op), - section: describeDatabaseStepSection(step.op), - groupId: `database:${plan.blockId}`, - groupLabel: `Database "${plan.blockId}"`, - label: describeDatabaseStepLabel(step.op), - summary: describeDatabaseStepSummary(plan.blockId, step), - detail: describeDatabaseStepDetail(beforeSnapshot, step), - preview: describeDatabaseStepPreview(step), - before: describeDatabaseStepBefore(beforeSnapshot, step), - after: describeDatabaseStepAfter(beforeSnapshot, step), - comparisonRows: describeDatabaseStepComparisonRows(beforeSnapshot, step), - stepIndex: index, - }), - ); - if (snapshot) { - applyDatabaseStepToReviewSnapshot(snapshot, step); - } - } - - if (snapshot) { - context.virtualBlocks.set(plan.blockId, { - type: "database", - database: cloneDatabaseReviewSnapshot(snapshot), - }); - } - - return items; -} - -function createReviewItem( - bundlePath: number[], - planKind: DocumentMutationPlan["kind"], - targetKind: StructuralReviewItem["targetKind"], - input: { - changeKind: StructuralReviewItem["changeKind"]; - section: StructuralReviewItem["section"]; - groupId: string; - groupLabel: string; - label: string; - summary: string; - detail?: string; - preview?: string; - before?: string; - after?: string; - comparisonRows?: StructuralReviewComparisonRow[]; - stepIndex?: number; - }, -): StructuralReviewItem { - const stepIndex = input.stepIndex ?? null; - return { - id: createReviewItemId(planKind, bundlePath, stepIndex), - targetKind, - planKind, - changeKind: input.changeKind, - section: input.section, - groupId: input.groupId, - groupLabel: input.groupLabel, - label: input.label, - summary: input.summary, - detail: input.detail, - preview: input.preview, - before: input.before, - after: input.after, - comparisonRows: input.comparisonRows, - bundlePath, - stepIndex, - }; -} - -function createReviewItemId( - planKind: DocumentMutationPlan["kind"], - bundlePath: number[], - stepIndex: number | null, -): string { - const pathPart = bundlePath.length > 0 ? bundlePath.join(".") : "root"; - const stepPart = stepIndex == null ? "plan" : `step-${stepIndex}`; - return `plan:${planKind}:${pathPart}:${stepPart}`; -} - -function selectPlanAtPath( - plan: DocumentMutationPlan, - bundlePath: number[], - stepIndex: number | null, -): DocumentMutationPlan | null { - if (bundlePath.length > 0) { - if (plan.kind !== "review_bundle") { - return null; - } - const [head, ...tail] = bundlePath; - const nestedPlan = plan.plans[head]; - if (!nestedPlan) { - return null; - } - return selectPlanAtPath(nestedPlan, tail, stepIndex); - } - - if (stepIndex == null) { - return plan; - } - - if (plan.kind === "database_edit") { - const step = plan.steps[stepIndex]; - return step ? { ...plan, steps: [step] } : null; - } - if (plan.kind === "flow_patch") { - const edit = plan.edits[stepIndex]; - return edit ? { ...plan, edits: [edit] } : null; - } - - return null; -} - -function removePlanAtPath( - plan: DocumentMutationPlan, - bundlePath: number[], - stepIndex: number | null, -): DocumentMutationPlan | null { - if (bundlePath.length > 0) { - if (plan.kind !== "review_bundle") { - return null; - } - const [head, ...tail] = bundlePath; - const nestedPlan = plan.plans[head]; - if (!nestedPlan) { - return plan; - } - const nextNestedPlan = removePlanAtPath(nestedPlan, tail, stepIndex); - const nextPlans = plan.plans.flatMap((entry, index) => { - if (index !== head) { - return [entry]; - } - return nextNestedPlan ? [nextNestedPlan] : []; - }); - if (nextPlans.length === 0) { - return null; - } - if (nextPlans.length === 1) { - return nextPlans[0] ?? null; - } - return { ...plan, plans: nextPlans }; - } - - if (stepIndex == null) { - return null; - } - - if (plan.kind === "database_edit") { - const nextSteps = plan.steps.filter((_, index) => index !== stepIndex); - return nextSteps.length > 0 ? { ...plan, steps: nextSteps } : null; - } - if (plan.kind === "flow_patch") { - const nextEdits = plan.edits.filter((_, index) => index !== stepIndex); - return nextEdits.length > 0 ? { ...plan, edits: nextEdits } : null; - } - - return null; -} - -function describeTextEditLabel( - operation: "replace" | "insert" | "append", -): string { - if (operation === "replace") { - return "Replace text"; - } - if (operation === "insert") { - return "Insert text"; - } - return "Append text"; -} - -function describeTextEditChangeKind( - operation: "replace" | "insert" | "append", -): StructuralReviewItem["changeKind"] { - return operation === "replace" ? "updated" : "added"; -} - -function describeDatabaseStepLabel(step: string): string { - switch (step) { - case "add_column": - return "Add column"; - case "update_column": - return "Update column"; - case "insert_row": - return "Insert row"; - case "update_cell": - return "Update cell"; - case "add_view": - return "Add view"; - case "set_active_view": - return "Set active view"; - default: - return "Database change"; - } -} - -function describeDatabaseStepChangeKind( - step: string, -): StructuralReviewItem["changeKind"] { - switch (step) { - case "add_column": - case "insert_row": - case "add_view": - return "added"; - case "update_column": - case "update_cell": - case "set_active_view": - default: - return "updated"; - } -} - -function describeDatabaseStepSection( - step: string, -): StructuralReviewItem["section"] { - switch (step) { - case "add_column": - case "update_column": - return "schema"; - case "insert_row": - return "row"; - case "update_cell": - return "cell"; - case "add_view": - case "set_active_view": - default: - return "view"; - } -} - -function describeDatabaseStepSummary( - blockId: string, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string { - switch (step.op) { - case "add_column": - return `Adds a column to database "${blockId}".`; - case "update_column": - return `Updates a column in database "${blockId}".`; - case "insert_row": - return `Adds a row to database "${blockId}".`; - case "update_cell": - return `Updates a database cell in "${blockId}".`; - case "add_view": - return `Adds a view to database "${blockId}".`; - case "set_active_view": - return `Changes the active view for database "${blockId}".`; - } -} - -function describeDatabaseStepDetail( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "add_column": - return resolveColumnLabel(step.column); - case "update_column": - return resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId); - case "insert_row": - return formatDatabaseValueKeys(snapshot?.columns ?? [], step.values); - case "update_cell": - return `${resolveDatabaseRowLabel(snapshot, step.rowId)} · ${resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId)}`; - case "add_view": - return resolveViewLabel(step.view); - case "set_active_view": - return ( - snapshot?.views.find((view) => view.id === step.viewId)?.title ?? - snapshot?.views.find((view) => view.id === step.viewId)?.id ?? - step.viewId - ); - } -} - -function describeDatabaseStepPreview( - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "update_cell": - return stringifyReviewValue(step.value); - case "insert_row": - return stringifyReviewValue(step.values); - default: - return undefined; - } -} - -function describeDatabaseStepBefore( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "add_column": - return summarizeColumns(snapshot?.columns ?? []); - case "update_column": - return formatColumnSchema( - snapshot?.columns.find((column) => column.id === step.columnId), - ); - case "insert_row": - return snapshot ? `${snapshot.rows.length} rows` : undefined; - case "update_cell": { - if (!snapshot) { - return undefined; - } - const rowIndex = findDatabaseReviewRowIndex(snapshot, step.rowId); - const colIndex = findColumnIndex(snapshot.columns, step.columnId); - if (rowIndex === -1 || colIndex === -1) { - return undefined; - } - const columnId = snapshot.columns[colIndex]?.id; - return columnId ? snapshot.rows[rowIndex]?.values[columnId] ?? "" : undefined; - } - case "add_view": - return summarizeViews(snapshot?.views ?? []); - case "set_active_view": - return snapshot ? resolveViewLabel(resolveDatabaseActiveViewSnapshot(snapshot)) : undefined; - } -} - -function describeDatabaseStepAfter( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): string | undefined { - switch (step.op) { - case "add_column": - return formatColumnSchema(step.column); - case "update_column": { - const column = snapshot?.columns.find((entry) => entry.id === step.columnId); - return formatColumnSchema(column ? { ...column, ...step.patch } : undefined); - } - case "insert_row": - return snapshot ? `${snapshot.rows.length + 1} rows` : undefined; - case "update_cell": - return stringifyReviewValue(step.value); - case "add_view": - return formatViewState(step.view, snapshot?.columns ?? []); - case "set_active_view": { - const nextView = snapshot?.views.find((view) => view.id === step.viewId); - return resolveViewLabel(nextView) ?? step.viewId; - } - } -} - -function describeDatabaseStepComparisonRows( - snapshot: DatabaseReviewSnapshot | null, - step: Extract< - DocumentMutationPlan, - { kind: "database_edit" } - >["steps"][number], -): StructuralReviewComparisonRow[] | undefined { - switch (step.op) { - case "add_column": - return [ - { - label: "Column", - before: undefined, - after: formatColumnSchema(step.column), - changeKind: "added", - section: "schema", - }, - ]; - case "update_column": { - const column = snapshot?.columns.find((entry) => entry.id === step.columnId); - const nextColumn = column ? { ...column, ...step.patch } : undefined; - if (!column && !nextColumn) { - return undefined; - } - return buildColumnSchemaComparisonRows(column, nextColumn); - } - case "add_view": - return buildViewComparisonRows(undefined, step.view, snapshot?.columns ?? []); - case "set_active_view": - return buildViewComparisonRows( - resolveDatabaseActiveViewSnapshot(snapshot) ?? undefined, - snapshot?.views.find((view) => view.id === step.viewId), - snapshot?.columns ?? [], - ); - default: - return undefined; - } -} - -function stringifyReviewValue(value: unknown): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function readTextEditBefore( - editor: Editor, - plan: Extract, -): string | undefined { - const block = editor.getBlock(plan.target.blockId); - if (!block) { - return undefined; - } - const text = block.textContent(); - if (plan.target.range) { - return text.slice( - plan.target.range.startOffset, - plan.target.range.endOffset, - ); - } - return text; -} - -function readBlockPropsPreview(editor: Editor, blockId: string): string | undefined { - const block = editor.getBlock(blockId); - return block ? stringifyReviewValue(block.props) : undefined; -} - -function readBlockTypePreview(editor: Editor, blockId: string): string | undefined { - const block = editor.getBlock(blockId); - return block?.type; -} - -function registerInsertedReviewBlock( - context: StructuralReviewBuildContext, - plan: Extract, -): void { - if (!plan.blockId) { - return; - } - if (plan.blockType === "database") { - context.virtualBlocks.set(plan.blockId, { - type: "database", - database: createDefaultDatabaseReviewSnapshot(), - }); - } -} - -function describeInsertedBlockAfter( - plan: Extract, -): string | undefined { - if (plan.initialText) { - return plan.initialText; - } - if (plan.blockType === "database") { - return "3 columns, 0 rows, 1 view"; - } - return plan.blockType; -} - -function getDatabaseReviewSnapshot( - editor: Editor, - blockId: string, - context: StructuralReviewBuildContext, -): DatabaseReviewSnapshot | null { - const virtualBlock = context.virtualBlocks.get(blockId); - if (virtualBlock?.type === "database") { - return cloneDatabaseReviewSnapshot(virtualBlock.database); - } - const block = editor.getBlock(blockId); - if (!block || block.type !== "database") { - return null; - } - const columns = [...block.tableColumns()]; - const rows = Array.from({ length: block.tableRowCount() }, (_, rowIndex) => { - const rowId = block.tableRow(rowIndex)?.id ?? `row-${rowIndex + 1}`; - return { - id: rowId, - values: Object.fromEntries( - columns.map((column, colIndex) => [ - column.id, - block.tableCell(rowIndex, colIndex)?.textContent() ?? "", - ]), - ), - }; - }); - return { - columns, - rows, - views: [...block.databaseViews()], - primaryViewId: block.databasePrimaryViewId(), - }; -} - -function cloneDatabaseReviewSnapshot( - snapshot: DatabaseReviewSnapshot, -): DatabaseReviewSnapshot { - return { - columns: snapshot.columns.map((column) => ({ ...column })), - rows: snapshot.rows.map((row) => ({ - id: row.id, - values: { ...row.values }, - })), - views: snapshot.views.map((view) => ({ - ...view, - visibleColumnIds: view.visibleColumnIds ? [...view.visibleColumnIds] : undefined, - columnOrder: view.columnOrder ? [...view.columnOrder] : undefined, - sort: view.sort ? [...view.sort] : undefined, - rowPinning: view.rowPinning ? { ...view.rowPinning } : undefined, - })), - primaryViewId: snapshot.primaryViewId, - }; -} - -function createDefaultDatabaseReviewSnapshot(): DatabaseReviewSnapshot { - const columns: TableColumnSchema[] = [ - { id: "name", title: "Name", type: "text" }, - { id: "tags", title: "Tags", type: "select" }, - { id: "done", title: "Done", type: "checkbox" }, - ]; - const primaryViewId = "view-table"; - return { - columns, - rows: [], - views: [ - { - id: primaryViewId, - title: "Table", - type: "table", - visibleColumnIds: columns.map((column) => column.id), - columnOrder: columns.map((column) => column.id), - }, - ], - primaryViewId, - }; -} - -function applyDatabaseStepToReviewSnapshot( - snapshot: DatabaseReviewSnapshot, - step: Extract["steps"][number], -): void { - switch (step.op) { - case "add_column": - snapshot.columns.push({ ...step.column }); - for (const row of snapshot.rows) { - row.values[step.column.id] = ""; - } - return; - case "update_column": { - const columnIndex = snapshot.columns.findIndex( - (column) => column.id === step.columnId, - ); - if (columnIndex !== -1) { - snapshot.columns[columnIndex] = { - ...snapshot.columns[columnIndex]!, - ...step.patch, - }; - } - return; - } - case "insert_row": - snapshot.rows.push({ - id: step.rowId ?? `row-${snapshot.rows.length + 1}`, - values: stringifyRecord(step.values), - }); - return; - case "update_cell": { - const row = snapshot.rows.find((entry) => entry.id === step.rowId); - if (row) { - row.values[step.columnId] = stringifyDatabaseValue(step.value); - } - return; - } - case "add_view": - snapshot.views.push({ - ...step.view, - visibleColumnIds: step.view.visibleColumnIds - ? [...step.view.visibleColumnIds] - : undefined, - columnOrder: step.view.columnOrder ? [...step.view.columnOrder] : undefined, - sort: step.view.sort ? [...step.view.sort] : undefined, - rowPinning: step.view.rowPinning ? { ...step.view.rowPinning } : undefined, - }); - return; - case "set_active_view": - snapshot.primaryViewId = step.viewId; - return; - } -} - -function summarizeColumns(columns: readonly TableColumnSchema[]): string | undefined { - if (columns.length === 0) { - return undefined; - } - return columns.map(formatColumnSchema).filter(Boolean).join(", "); -} - -function summarizeViews(views: readonly DatabaseViewState[]): string | undefined { - if (views.length === 0) { - return undefined; - } - return views.map((view) => resolveViewLabel(view)).filter(Boolean).join(", "); -} - -function findDatabaseReviewRowIndex( - snapshot: DatabaseReviewSnapshot, - rowId: string, -): number { - for (let index = 0; index < snapshot.rows.length; index += 1) { - if (snapshot.rows[index]?.id === rowId) { - return index; - } - } - return -1; -} - -function findColumnIndex( - columns: readonly TableColumnSchema[], - columnId: string, -): number { - return columns.findIndex((column) => column.id === columnId); -} - -function resolveColumnLabel(column: TableColumnSchema | undefined): string { - return column?.title || column?.id || "Column"; -} - -function resolveDatabaseColumnLabel( - columns: readonly TableColumnSchema[], - columnId: string, -): string { - const column = columns.find((entry) => entry.id === columnId); - return column ? resolveColumnLabel(column) : columnId; -} - -function resolveDatabaseRowLabel( - snapshot: DatabaseReviewSnapshot | null, - rowId: string, -): string { - if (!snapshot) { - return rowId; - } - const rowIndex = findDatabaseReviewRowIndex(snapshot, rowId); - if (rowIndex === -1) { - return rowId; - } - - const columns = snapshot.columns; - const preferredColumnIds = [ - columns.find((column) => column.title.toLowerCase() === "name")?.id, - columns[0]?.id, - ].filter(Boolean) as string[]; - - for (const columnId of preferredColumnIds) { - const value = snapshot.rows[rowIndex]?.values[columnId]?.trim(); - if (value) { - return value; - } - } - - return `Row ${rowIndex + 1}`; -} - -function resolveDatabaseActiveViewSnapshot( - snapshot: DatabaseReviewSnapshot | null, -): DatabaseViewState | null { - if (!snapshot) { - return null; - } - if (!snapshot.primaryViewId) { - return snapshot.views[0] ?? null; - } - return ( - snapshot.views.find((view) => view.id === snapshot.primaryViewId) ?? - snapshot.views[0] ?? - null - ); -} - -function resolveViewLabel(view: DatabaseViewState | null | undefined): string | undefined { - if (!view) { - return undefined; - } - return view.title ?? view.id; -} - -function formatColumnSchema( - column: TableColumnSchema | undefined, -): string | undefined { - if (!column) { - return undefined; - } - - const parts = [`${resolveColumnLabel(column)} [${column.type}]`]; - if (column.width != null) { - parts.push(`w:${column.width}`); - } - if (column.hidden) { - parts.push("hidden"); - } - if (column.pinned) { - parts.push(`pinned:${column.pinned}`); - } - return parts.join(" "); -} - -function formatViewState( - view: DatabaseViewState | undefined, - columns: readonly TableColumnSchema[], -): string | undefined { - if (!view) { - return undefined; - } - - const parts = [`${resolveViewLabel(view)} [${view.type}]`]; - if (view.groupBy) { - parts.push(`group:${resolveDatabaseColumnLabel(columns, view.groupBy)}`); - } - if (view.visibleColumnIds && view.visibleColumnIds.length > 0) { - parts.push( - `visible:${view.visibleColumnIds - .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) - .join(", ")}`, - ); - } - return parts.join(" "); -} - -function formatDatabaseValueKeys( - columns: readonly TableColumnSchema[], - values: Record, -): string | undefined { - const keys = Object.keys(values); - if (keys.length === 0) { - return undefined; - } - return keys - .map((key) => resolveDatabaseColumnLabel(columns, key)) - .join(", "); -} - -function stringifyRecord( - value: Record, -): Record { - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [ - key, - stringifyDatabaseValue(entryValue), - ]), - ); -} - -function stringifyDatabaseValue(value: unknown): string { - if (value == null) { - return ""; - } - if (typeof value === "string") { - return value; - } - if ( - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function buildColumnComparisonRows( - beforeColumns: readonly TableColumnSchema[], - afterColumns: readonly TableColumnSchema[], -): StructuralReviewComparisonRow[] | undefined { - const rows: StructuralReviewComparisonRow[] = []; - const beforeOrder = beforeColumns.map((column) => resolveColumnLabel(column)).join(", "); - const afterOrder = afterColumns.map((column) => resolveColumnLabel(column)).join(", "); - if (beforeOrder !== afterOrder) { - rows.push({ - label: "Order", - before: beforeOrder || undefined, - after: afterOrder || undefined, - changeKind: "updated", - section: "schema", - }); - } - - const beforeById = new Map(beforeColumns.map((column) => [column.id, column])); - const afterById = new Map(afterColumns.map((column) => [column.id, column])); - const allIds = [...new Set([...beforeById.keys(), ...afterById.keys()])]; - - for (const id of allIds) { - const beforeColumn = beforeById.get(id); - const afterColumn = afterById.get(id); - if (!beforeColumn && afterColumn) { - rows.push({ - label: `Added ${resolveColumnLabel(afterColumn)}`, - after: formatColumnSchema(afterColumn), - changeKind: "added", - section: "schema", - }); - continue; - } - if (beforeColumn && !afterColumn) { - rows.push({ - label: `Removed ${resolveColumnLabel(beforeColumn)}`, - before: formatColumnSchema(beforeColumn), - changeKind: "removed", - section: "schema", - }); - continue; - } - if (!beforeColumn || !afterColumn) { - continue; - } - if (!areColumnSchemasEqual(beforeColumn, afterColumn)) { - rows.push({ - label: resolveColumnLabel(afterColumn), - before: formatColumnSchema(beforeColumn), - after: formatColumnSchema(afterColumn), - changeKind: "updated", - section: "schema", - }); - } - } - - return rows.length > 0 ? rows : undefined; -} - -function buildColumnSchemaComparisonRows( - beforeColumn: TableColumnSchema | undefined, - afterColumn: TableColumnSchema | undefined, -): StructuralReviewComparisonRow[] | undefined { - if (!beforeColumn && !afterColumn) { - return undefined; - } - - const rows: StructuralReviewComparisonRow[] = []; - const label = resolveColumnLabel(afterColumn ?? beforeColumn); - rows.push({ - label, - before: formatColumnSchema(beforeColumn), - after: formatColumnSchema(afterColumn), - changeKind: - beforeColumn == null ? "added" : afterColumn == null ? "removed" : "updated", - section: "schema", - }); - - return rows; -} - -function buildViewComparisonRows( - beforeView: DatabaseViewState | undefined, - afterView: DatabaseViewState | undefined, - columns: readonly TableColumnSchema[], -): StructuralReviewComparisonRow[] | undefined { - if (!beforeView && !afterView) { - return undefined; - } - - const rows: StructuralReviewComparisonRow[] = [ - { - label: "View", - before: resolveViewLabel(beforeView), - after: resolveViewLabel(afterView), - changeKind: - beforeView == null ? "added" : afterView == null ? "removed" : "updated", - section: "view", - }, - { - label: "Type", - before: beforeView?.type, - after: afterView?.type, - changeKind: "updated", - section: "view", - }, - { - label: "Group by", - before: beforeView?.groupBy - ? resolveDatabaseColumnLabel(columns, beforeView.groupBy) - : undefined, - after: afterView?.groupBy - ? resolveDatabaseColumnLabel(columns, afterView.groupBy) - : undefined, - changeKind: "updated", - section: "view", - }, - { - label: "Visible columns", - before: beforeView?.visibleColumnIds?.length - ? beforeView.visibleColumnIds - .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) - .join(", ") - : undefined, - after: afterView?.visibleColumnIds?.length - ? afterView.visibleColumnIds - .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) - .join(", ") - : undefined, - changeKind: "updated", - section: "view", - }, - { - label: "Sort", - before: formatViewSort(beforeView, columns), - after: formatViewSort(afterView, columns), - changeKind: "updated", - section: "view", - }, - ]; - - const meaningfulRows = rows.filter((row) => row.before !== row.after); - return meaningfulRows.length > 0 ? meaningfulRows : undefined; -} - -function areColumnSchemasEqual( - left: TableColumnSchema, - right: TableColumnSchema, -): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -function formatViewSort( - view: DatabaseViewState | undefined, - columns: readonly TableColumnSchema[], -): string | undefined { - if (!view?.sort || view.sort.length === 0) { - return undefined; - } - return view.sort - .map( - (sortEntry) => - `${resolveDatabaseColumnLabel(columns, sortEntry.columnId)} ${sortEntry.direction}`, - ) - .join(", "); -} +export { buildStructuralReviewItems, buildStructuredPreviewTargets, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan } from "./reviewArtifactsParts/reviewArtifactsPart1"; +export type { StructuralReviewItem, StructuralReviewComparisonRow, StructuredPreviewDatabaseState, StructuredPreviewTargetState } from "./reviewArtifactsParts/reviewArtifactsPart1"; diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts new file mode 100644 index 0000000..16a780f --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart1.ts @@ -0,0 +1,340 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { selectPlanAtPath, removePlanAtPath, describeTextEditLabel, describeTextEditChangeKind, describeDatabaseStepLabel, describeDatabaseStepChangeKind, describeDatabaseStepSection, describeDatabaseStepSummary, describeDatabaseStepDetail, describeDatabaseStepPreview, describeDatabaseStepBefore, describeDatabaseStepAfter, describeDatabaseStepComparisonRows, stringifyReviewValue, readTextEditBefore, readBlockPropsPreview, readBlockTypePreview } from "./reviewArtifactsPart2"; +import { registerInsertedReviewBlock, describeInsertedBlockAfter, getDatabaseReviewSnapshot, cloneDatabaseReviewSnapshot, createDefaultDatabaseReviewSnapshot, applyDatabaseStepToReviewSnapshot, summarizeColumns, summarizeViews, findDatabaseReviewRowIndex, findColumnIndex, resolveColumnLabel, resolveDatabaseColumnLabel, resolveDatabaseRowLabel, resolveDatabaseActiveViewSnapshot, resolveViewLabel, formatColumnSchema, formatViewState, formatDatabaseValueKeys, stringifyRecord, stringifyDatabaseValue } from "./reviewArtifactsPart3"; +import { buildColumnComparisonRows, buildColumnSchemaComparisonRows, buildViewComparisonRows, areColumnSchemasEqual, formatViewSort } from "./reviewArtifactsPart4"; + +export interface StructuralReviewItem { + id: string; + targetKind: AITargetKind | "bundle"; + planKind: DocumentMutationPlan["kind"]; + changeKind: "added" | "removed" | "updated" | "moved"; + section: "content" | "block" | "row" | "cell" | "schema" | "view"; + groupId: string; + groupLabel: string; + label: string; + summary: string; + detail?: string; + preview?: string; + before?: string; + after?: string; + comparisonRows?: StructuralReviewComparisonRow[]; + bundlePath: number[]; + stepIndex: number | null; +} + +export interface StructuralReviewComparisonRow { + label: string; + before?: string; + after?: string; + changeKind: "added" | "removed" | "updated"; + section: "schema" | "view"; +} + +export interface StructuralReviewBuildContext { + virtualBlocks: Map; +} + +export interface DatabaseReviewSnapshot { + columns: TableColumnSchema[]; + rows: Array<{ + id: string; + values: Record; + }>; + views: DatabaseViewState[]; + primaryViewId: string | null; +} + +export interface StructuredPreviewDatabaseState { + columns: TableColumnSchema[]; + rows: Array<{ + id: string; + values: Record; + }>; + views: DatabaseViewState[]; + primaryViewId: string | null; +} + +export interface StructuredPreviewTargetState { + blockId: string; + targetKind: "database"; + database: StructuredPreviewDatabaseState; +} + +export type VirtualReviewBlock = { + type: "database"; + database: DatabaseReviewSnapshot; +}; + +export function buildStructuralReviewItems( + editor: Editor, + plan: DocumentMutationPlan, +): StructuralReviewItem[] { + return buildStructuralPreviewArtifacts(editor, plan).reviewItems; +} + +export function buildStructuredPreviewTargets( + editor: Editor, + plan: DocumentMutationPlan, +): StructuredPreviewTargetState[] { + return buildStructuralPreviewArtifacts(editor, plan).targets; +} + +export function buildStructuralPreviewArtifacts( + editor: Editor, + plan: DocumentMutationPlan, +): { + reviewItems: StructuralReviewItem[]; + targets: StructuredPreviewTargetState[]; +} { + const context: StructuralReviewBuildContext = { + virtualBlocks: new Map(), + }; + const reviewItems = buildReviewItemsForPlan(editor, plan, [], context); + return { + reviewItems, + targets: serializeStructuredPreviewTargets(context.virtualBlocks), + }; +} + +export function selectStructuralReviewItemPlan( + plan: DocumentMutationPlan, + item: StructuralReviewItem, +): DocumentMutationPlan | null { + return selectPlanAtPath(plan, item.bundlePath, item.stepIndex); +} + +export function removeStructuralReviewItemPlan( + plan: DocumentMutationPlan, + item: StructuralReviewItem, +): DocumentMutationPlan | null { + return removePlanAtPath(plan, item.bundlePath, item.stepIndex); +} + +export function buildReviewItemsForPlan( + editor: Editor, + plan: DocumentMutationPlan, + bundlePath: number[], + context: StructuralReviewBuildContext, +): StructuralReviewItem[] { + switch (plan.kind) { + case "text_edit": + return [ + createReviewItem(bundlePath, plan.kind, "text", { + changeKind: describeTextEditChangeKind(plan.operation), + section: "content", + groupId: `block:${plan.target.blockId}`, + groupLabel: `Block "${plan.target.blockId}"`, + label: describeTextEditLabel(plan.operation), + summary: "Updates the selected text range.", + preview: plan.text, + before: readTextEditBefore(editor, plan), + after: plan.text, + }), + ]; + case "flow_patch": + return plan.edits.map((edit, index) => + createReviewItem(bundlePath, plan.kind, "text", { + changeKind: + edit.operation === "append_text" || edit.operation === "insert_after" || edit.operation === "insert_before" + ? "added" + : edit.operation === "delete_blocks" + ? "removed" + : "updated", + section: "content", + groupId: + edit.locator.blockId != null + ? `block:${edit.locator.blockId}` + : `span:${plan.targetSpanId ?? "flow-patch"}`, + groupLabel: + edit.locator.blockId != null + ? `Block "${edit.locator.blockId}"` + : `Span "${plan.targetSpanId ?? "flow-patch"}"`, + label: `Flow patch: ${edit.operation}`, + summary: plan.instructions, + detail: edit.locator.expectedBlockType, + preview: edit.text ?? edit.markdown, + before: + edit.locator.blockId != null + ? editor.getBlock(edit.locator.blockId)?.textContent() ?? undefined + : undefined, + after: edit.text ?? edit.markdown, + stepIndex: index, + }), + ); + case "block_insert": + registerInsertedReviewBlock(context, plan); + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "added", + section: "block", + groupId: "blocks", + groupLabel: "Blocks", + label: "Insert block", + summary: `Adds a new ${plan.blockType} block.`, + detail: plan.blockType, + preview: plan.initialText, + before: "(new block)", + after: describeInsertedBlockAfter(plan), + }), + ]; + case "block_update": + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "updated", + section: "block", + groupId: `block:${plan.blockId}`, + groupLabel: `Block "${plan.blockId}"`, + label: "Update block", + summary: "Updates block properties.", + detail: `${Object.keys(plan.props).length} prop changes`, + before: readBlockPropsPreview(editor, plan.blockId), + after: stringifyReviewValue(plan.props), + }), + ]; + case "block_move": + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "moved", + section: "block", + groupId: `block:${plan.blockId}`, + groupLabel: `Block "${plan.blockId}"`, + label: "Move block", + summary: "Moves this block to a new position.", + }), + ]; + case "block_convert": + return [ + createReviewItem(bundlePath, plan.kind, "block", { + changeKind: "updated", + section: "block", + groupId: `block:${plan.blockId}`, + groupLabel: `Block "${plan.blockId}"`, + label: "Convert block", + summary: `Converts this block to ${plan.newType}.`, + detail: plan.newType, + before: readBlockTypePreview(editor, plan.blockId), + after: plan.newType, + }), + ]; + case "database_edit": + return buildDatabaseReviewItems( + editor, + plan, + bundlePath, + context, + ); + case "review_bundle": + return plan.plans.flatMap((nestedPlan, index) => + buildReviewItemsForPlan(editor, nestedPlan, [...bundlePath, index], context), + ); + } +} + +export function serializeStructuredPreviewTargets( + virtualBlocks: Map, +): StructuredPreviewTargetState[] { + return [...virtualBlocks.entries()].map(([blockId, virtualBlock]) => { + return { + blockId, + targetKind: "database", + database: cloneDatabaseReviewSnapshot(virtualBlock.database), + }; + }); +} + +export function buildDatabaseReviewItems( + editor: Editor, + plan: Extract, + bundlePath: number[], + context: StructuralReviewBuildContext, +): StructuralReviewItem[] { + const snapshot = getDatabaseReviewSnapshot(editor, plan.blockId, context); + const items: StructuralReviewItem[] = []; + + for (let index = 0; index < plan.steps.length; index += 1) { + const step = plan.steps[index]!; + const beforeSnapshot = snapshot ? cloneDatabaseReviewSnapshot(snapshot) : null; + items.push( + createReviewItem(bundlePath, plan.kind, "database", { + changeKind: describeDatabaseStepChangeKind(step.op), + section: describeDatabaseStepSection(step.op), + groupId: `database:${plan.blockId}`, + groupLabel: `Database "${plan.blockId}"`, + label: describeDatabaseStepLabel(step.op), + summary: describeDatabaseStepSummary(plan.blockId, step), + detail: describeDatabaseStepDetail(beforeSnapshot, step), + preview: describeDatabaseStepPreview(step), + before: describeDatabaseStepBefore(beforeSnapshot, step), + after: describeDatabaseStepAfter(beforeSnapshot, step), + comparisonRows: describeDatabaseStepComparisonRows(beforeSnapshot, step), + stepIndex: index, + }), + ); + if (snapshot) { + applyDatabaseStepToReviewSnapshot(snapshot, step); + } + } + + if (snapshot) { + context.virtualBlocks.set(plan.blockId, { + type: "database", + database: cloneDatabaseReviewSnapshot(snapshot), + }); + } + + return items; +} + +export function createReviewItem( + bundlePath: number[], + planKind: DocumentMutationPlan["kind"], + targetKind: StructuralReviewItem["targetKind"], + input: { + changeKind: StructuralReviewItem["changeKind"]; + section: StructuralReviewItem["section"]; + groupId: string; + groupLabel: string; + label: string; + summary: string; + detail?: string; + preview?: string; + before?: string; + after?: string; + comparisonRows?: StructuralReviewComparisonRow[]; + stepIndex?: number; + }, +): StructuralReviewItem { + const stepIndex = input.stepIndex ?? null; + return { + id: createReviewItemId(planKind, bundlePath, stepIndex), + targetKind, + planKind, + changeKind: input.changeKind, + section: input.section, + groupId: input.groupId, + groupLabel: input.groupLabel, + label: input.label, + summary: input.summary, + detail: input.detail, + preview: input.preview, + before: input.before, + after: input.after, + comparisonRows: input.comparisonRows, + bundlePath, + stepIndex, + }; +} + +export function createReviewItemId( + planKind: DocumentMutationPlan["kind"], + bundlePath: number[], + stepIndex: number | null, +): string { + const pathPart = bundlePath.length > 0 ? bundlePath.join(".") : "root"; + const stepPart = stepIndex == null ? "plan" : `step-${stepIndex}`; + return `plan:${planKind}:${pathPart}:${stepPart}`; +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts new file mode 100644 index 0000000..e206fb7 --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart2.ts @@ -0,0 +1,368 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { buildStructuralReviewItems, buildStructuredPreviewTargets, buildStructuralPreviewArtifacts, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan, buildReviewItemsForPlan, serializeStructuredPreviewTargets, buildDatabaseReviewItems, createReviewItem, createReviewItemId } from "./reviewArtifactsPart1"; +import type { StructuralReviewItem, StructuralReviewComparisonRow, StructuralReviewBuildContext, DatabaseReviewSnapshot, StructuredPreviewDatabaseState, StructuredPreviewTargetState, VirtualReviewBlock } from "./reviewArtifactsPart1"; +import { registerInsertedReviewBlock, describeInsertedBlockAfter, getDatabaseReviewSnapshot, cloneDatabaseReviewSnapshot, createDefaultDatabaseReviewSnapshot, applyDatabaseStepToReviewSnapshot, summarizeColumns, summarizeViews, findDatabaseReviewRowIndex, findColumnIndex, resolveColumnLabel, resolveDatabaseColumnLabel, resolveDatabaseRowLabel, resolveDatabaseActiveViewSnapshot, resolveViewLabel, formatColumnSchema, formatViewState, formatDatabaseValueKeys, stringifyRecord, stringifyDatabaseValue } from "./reviewArtifactsPart3"; +import { buildColumnComparisonRows, buildColumnSchemaComparisonRows, buildViewComparisonRows, areColumnSchemasEqual, formatViewSort } from "./reviewArtifactsPart4"; + +export function selectPlanAtPath( + plan: DocumentMutationPlan, + bundlePath: number[], + stepIndex: number | null, +): DocumentMutationPlan | null { + if (bundlePath.length > 0) { + if (plan.kind !== "review_bundle") { + return null; + } + const [head, ...tail] = bundlePath; + const nestedPlan = plan.plans[head]; + if (!nestedPlan) { + return null; + } + return selectPlanAtPath(nestedPlan, tail, stepIndex); + } + + if (stepIndex == null) { + return plan; + } + + if (plan.kind === "database_edit") { + const step = plan.steps[stepIndex]; + return step ? { ...plan, steps: [step] } : null; + } + if (plan.kind === "flow_patch") { + const edit = plan.edits[stepIndex]; + return edit ? { ...plan, edits: [edit] } : null; + } + + return null; +} + +export function removePlanAtPath( + plan: DocumentMutationPlan, + bundlePath: number[], + stepIndex: number | null, +): DocumentMutationPlan | null { + if (bundlePath.length > 0) { + if (plan.kind !== "review_bundle") { + return null; + } + const [head, ...tail] = bundlePath; + const nestedPlan = plan.plans[head]; + if (!nestedPlan) { + return plan; + } + const nextNestedPlan = removePlanAtPath(nestedPlan, tail, stepIndex); + const nextPlans = plan.plans.flatMap((entry, index) => { + if (index !== head) { + return [entry]; + } + return nextNestedPlan ? [nextNestedPlan] : []; + }); + if (nextPlans.length === 0) { + return null; + } + if (nextPlans.length === 1) { + return nextPlans[0] ?? null; + } + return { ...plan, plans: nextPlans }; + } + + if (stepIndex == null) { + return null; + } + + if (plan.kind === "database_edit") { + const nextSteps = plan.steps.filter((_, index) => index !== stepIndex); + return nextSteps.length > 0 ? { ...plan, steps: nextSteps } : null; + } + if (plan.kind === "flow_patch") { + const nextEdits = plan.edits.filter((_, index) => index !== stepIndex); + return nextEdits.length > 0 ? { ...plan, edits: nextEdits } : null; + } + + return null; +} + +export function describeTextEditLabel( + operation: "replace" | "insert" | "append", +): string { + if (operation === "replace") { + return "Replace text"; + } + if (operation === "insert") { + return "Insert text"; + } + return "Append text"; +} + +export function describeTextEditChangeKind( + operation: "replace" | "insert" | "append", +): StructuralReviewItem["changeKind"] { + return operation === "replace" ? "updated" : "added"; +} + +export function describeDatabaseStepLabel(step: string): string { + switch (step) { + case "add_column": + return "Add column"; + case "update_column": + return "Update column"; + case "insert_row": + return "Insert row"; + case "update_cell": + return "Update cell"; + case "add_view": + return "Add view"; + case "set_active_view": + return "Set active view"; + default: + return "Database change"; + } +} + +export function describeDatabaseStepChangeKind( + step: string, +): StructuralReviewItem["changeKind"] { + switch (step) { + case "add_column": + case "insert_row": + case "add_view": + return "added"; + case "update_column": + case "update_cell": + case "set_active_view": + default: + return "updated"; + } +} + +export function describeDatabaseStepSection( + step: string, +): StructuralReviewItem["section"] { + switch (step) { + case "add_column": + case "update_column": + return "schema"; + case "insert_row": + return "row"; + case "update_cell": + return "cell"; + case "add_view": + case "set_active_view": + default: + return "view"; + } +} + +export function describeDatabaseStepSummary( + blockId: string, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string { + switch (step.op) { + case "add_column": + return `Adds a column to database "${blockId}".`; + case "update_column": + return `Updates a column in database "${blockId}".`; + case "insert_row": + return `Adds a row to database "${blockId}".`; + case "update_cell": + return `Updates a database cell in "${blockId}".`; + case "add_view": + return `Adds a view to database "${blockId}".`; + case "set_active_view": + return `Changes the active view for database "${blockId}".`; + } +} + +export function describeDatabaseStepDetail( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "add_column": + return resolveColumnLabel(step.column); + case "update_column": + return resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId); + case "insert_row": + return formatDatabaseValueKeys(snapshot?.columns ?? [], step.values); + case "update_cell": + return `${resolveDatabaseRowLabel(snapshot, step.rowId)} · ${resolveDatabaseColumnLabel(snapshot?.columns ?? [], step.columnId)}`; + case "add_view": + return resolveViewLabel(step.view); + case "set_active_view": + return ( + snapshot?.views.find((view) => view.id === step.viewId)?.title ?? + snapshot?.views.find((view) => view.id === step.viewId)?.id ?? + step.viewId + ); + } +} + +export function describeDatabaseStepPreview( + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "update_cell": + return stringifyReviewValue(step.value); + case "insert_row": + return stringifyReviewValue(step.values); + default: + return undefined; + } +} + +export function describeDatabaseStepBefore( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "add_column": + return summarizeColumns(snapshot?.columns ?? []); + case "update_column": + return formatColumnSchema( + snapshot?.columns.find((column) => column.id === step.columnId), + ); + case "insert_row": + return snapshot ? `${snapshot.rows.length} rows` : undefined; + case "update_cell": { + if (!snapshot) { + return undefined; + } + const rowIndex = findDatabaseReviewRowIndex(snapshot, step.rowId); + const colIndex = findColumnIndex(snapshot.columns, step.columnId); + if (rowIndex === -1 || colIndex === -1) { + return undefined; + } + const columnId = snapshot.columns[colIndex]?.id; + return columnId ? snapshot.rows[rowIndex]?.values[columnId] ?? "" : undefined; + } + case "add_view": + return summarizeViews(snapshot?.views ?? []); + case "set_active_view": + return snapshot ? resolveViewLabel(resolveDatabaseActiveViewSnapshot(snapshot)) : undefined; + } +} + +export function describeDatabaseStepAfter( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): string | undefined { + switch (step.op) { + case "add_column": + return formatColumnSchema(step.column); + case "update_column": { + const column = snapshot?.columns.find((entry) => entry.id === step.columnId); + return formatColumnSchema(column ? { ...column, ...step.patch } : undefined); + } + case "insert_row": + return snapshot ? `${snapshot.rows.length + 1} rows` : undefined; + case "update_cell": + return stringifyReviewValue(step.value); + case "add_view": + return formatViewState(step.view, snapshot?.columns ?? []); + case "set_active_view": { + const nextView = snapshot?.views.find((view) => view.id === step.viewId); + return resolveViewLabel(nextView) ?? step.viewId; + } + } +} + +export function describeDatabaseStepComparisonRows( + snapshot: DatabaseReviewSnapshot | null, + step: Extract< + DocumentMutationPlan, + { kind: "database_edit" } + >["steps"][number], +): StructuralReviewComparisonRow[] | undefined { + switch (step.op) { + case "add_column": + return [ + { + label: "Column", + before: undefined, + after: formatColumnSchema(step.column), + changeKind: "added", + section: "schema", + }, + ]; + case "update_column": { + const column = snapshot?.columns.find((entry) => entry.id === step.columnId); + const nextColumn = column ? { ...column, ...step.patch } : undefined; + if (!column && !nextColumn) { + return undefined; + } + return buildColumnSchemaComparisonRows(column, nextColumn); + } + case "add_view": + return buildViewComparisonRows(undefined, step.view, snapshot?.columns ?? []); + case "set_active_view": + return buildViewComparisonRows( + resolveDatabaseActiveViewSnapshot(snapshot) ?? undefined, + snapshot?.views.find((view) => view.id === step.viewId), + snapshot?.columns ?? [], + ); + default: + return undefined; + } +} + +export function stringifyReviewValue(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function readTextEditBefore( + editor: Editor, + plan: Extract, +): string | undefined { + const block = editor.getBlock(plan.target.blockId); + if (!block) { + return undefined; + } + const text = block.textContent(); + if (plan.target.range) { + return text.slice( + plan.target.range.startOffset, + plan.target.range.endOffset, + ); + } + return text; +} + +export function readBlockPropsPreview(editor: Editor, blockId: string): string | undefined { + const block = editor.getBlock(blockId); + return block ? stringifyReviewValue(block.props) : undefined; +} + +export function readBlockTypePreview(editor: Editor, blockId: string): string | undefined { + const block = editor.getBlock(blockId); + return block?.type; +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts new file mode 100644 index 0000000..7f5d0e5 --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart3.ts @@ -0,0 +1,349 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { buildStructuralReviewItems, buildStructuredPreviewTargets, buildStructuralPreviewArtifacts, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan, buildReviewItemsForPlan, serializeStructuredPreviewTargets, buildDatabaseReviewItems, createReviewItem, createReviewItemId } from "./reviewArtifactsPart1"; +import type { StructuralReviewItem, StructuralReviewComparisonRow, StructuralReviewBuildContext, DatabaseReviewSnapshot, StructuredPreviewDatabaseState, StructuredPreviewTargetState, VirtualReviewBlock } from "./reviewArtifactsPart1"; +import { selectPlanAtPath, removePlanAtPath, describeTextEditLabel, describeTextEditChangeKind, describeDatabaseStepLabel, describeDatabaseStepChangeKind, describeDatabaseStepSection, describeDatabaseStepSummary, describeDatabaseStepDetail, describeDatabaseStepPreview, describeDatabaseStepBefore, describeDatabaseStepAfter, describeDatabaseStepComparisonRows, stringifyReviewValue, readTextEditBefore, readBlockPropsPreview, readBlockTypePreview } from "./reviewArtifactsPart2"; +import { buildColumnComparisonRows, buildColumnSchemaComparisonRows, buildViewComparisonRows, areColumnSchemasEqual, formatViewSort } from "./reviewArtifactsPart4"; + +export function registerInsertedReviewBlock( + context: StructuralReviewBuildContext, + plan: Extract, +): void { + if (!plan.blockId) { + return; + } + if (plan.blockType === "database") { + context.virtualBlocks.set(plan.blockId, { + type: "database", + database: createDefaultDatabaseReviewSnapshot(), + }); + } +} + +export function describeInsertedBlockAfter( + plan: Extract, +): string | undefined { + if (plan.initialText) { + return plan.initialText; + } + if (plan.blockType === "database") { + return "3 columns, 0 rows, 1 view"; + } + return plan.blockType; +} + +export function getDatabaseReviewSnapshot( + editor: Editor, + blockId: string, + context: StructuralReviewBuildContext, +): DatabaseReviewSnapshot | null { + const virtualBlock = context.virtualBlocks.get(blockId); + if (virtualBlock?.type === "database") { + return cloneDatabaseReviewSnapshot(virtualBlock.database); + } + const block = editor.getBlock(blockId); + if (!block || block.type !== "database") { + return null; + } + const columns = [...block.tableColumns()]; + const rows = Array.from({ length: block.tableRowCount() }, (_, rowIndex) => { + const rowId = block.tableRow(rowIndex)?.id ?? `row-${rowIndex + 1}`; + return { + id: rowId, + values: Object.fromEntries( + columns.map((column, colIndex) => [ + column.id, + block.tableCell(rowIndex, colIndex)?.textContent() ?? "", + ]), + ), + }; + }); + return { + columns, + rows, + views: [...block.databaseViews()], + primaryViewId: block.databasePrimaryViewId(), + }; +} + +export function cloneDatabaseReviewSnapshot( + snapshot: DatabaseReviewSnapshot, +): DatabaseReviewSnapshot { + return { + columns: snapshot.columns.map((column) => ({ ...column })), + rows: snapshot.rows.map((row) => ({ + id: row.id, + values: { ...row.values }, + })), + views: snapshot.views.map((view) => ({ + ...view, + visibleColumnIds: view.visibleColumnIds ? [...view.visibleColumnIds] : undefined, + columnOrder: view.columnOrder ? [...view.columnOrder] : undefined, + sort: view.sort ? [...view.sort] : undefined, + rowPinning: view.rowPinning ? { ...view.rowPinning } : undefined, + })), + primaryViewId: snapshot.primaryViewId, + }; +} + +export function createDefaultDatabaseReviewSnapshot(): DatabaseReviewSnapshot { + const columns: TableColumnSchema[] = [ + { id: "name", title: "Name", type: "text" }, + { id: "tags", title: "Tags", type: "select" }, + { id: "done", title: "Done", type: "checkbox" }, + ]; + const primaryViewId = "view-table"; + return { + columns, + rows: [], + views: [ + { + id: primaryViewId, + title: "Table", + type: "table", + visibleColumnIds: columns.map((column) => column.id), + columnOrder: columns.map((column) => column.id), + }, + ], + primaryViewId, + }; +} + +export function applyDatabaseStepToReviewSnapshot( + snapshot: DatabaseReviewSnapshot, + step: Extract["steps"][number], +): void { + switch (step.op) { + case "add_column": + snapshot.columns.push({ ...step.column }); + for (const row of snapshot.rows) { + row.values[step.column.id] = ""; + } + return; + case "update_column": { + const columnIndex = snapshot.columns.findIndex( + (column) => column.id === step.columnId, + ); + if (columnIndex !== -1) { + snapshot.columns[columnIndex] = { + ...snapshot.columns[columnIndex]!, + ...step.patch, + }; + } + return; + } + case "insert_row": + snapshot.rows.push({ + id: step.rowId ?? `row-${snapshot.rows.length + 1}`, + values: stringifyRecord(step.values), + }); + return; + case "update_cell": { + const row = snapshot.rows.find((entry) => entry.id === step.rowId); + if (row) { + row.values[step.columnId] = stringifyDatabaseValue(step.value); + } + return; + } + case "add_view": + snapshot.views.push({ + ...step.view, + visibleColumnIds: step.view.visibleColumnIds + ? [...step.view.visibleColumnIds] + : undefined, + columnOrder: step.view.columnOrder ? [...step.view.columnOrder] : undefined, + sort: step.view.sort ? [...step.view.sort] : undefined, + rowPinning: step.view.rowPinning ? { ...step.view.rowPinning } : undefined, + }); + return; + case "set_active_view": + snapshot.primaryViewId = step.viewId; + return; + } +} + +export function summarizeColumns(columns: readonly TableColumnSchema[]): string | undefined { + if (columns.length === 0) { + return undefined; + } + return columns.map(formatColumnSchema).filter(Boolean).join(", "); +} + +export function summarizeViews(views: readonly DatabaseViewState[]): string | undefined { + if (views.length === 0) { + return undefined; + } + return views.map((view) => resolveViewLabel(view)).filter(Boolean).join(", "); +} + +export function findDatabaseReviewRowIndex( + snapshot: DatabaseReviewSnapshot, + rowId: string, +): number { + for (let index = 0; index < snapshot.rows.length; index += 1) { + if (snapshot.rows[index]?.id === rowId) { + return index; + } + } + return -1; +} + +export function findColumnIndex( + columns: readonly TableColumnSchema[], + columnId: string, +): number { + return columns.findIndex((column) => column.id === columnId); +} + +export function resolveColumnLabel(column: TableColumnSchema | undefined): string { + return column?.title || column?.id || "Column"; +} + +export function resolveDatabaseColumnLabel( + columns: readonly TableColumnSchema[], + columnId: string, +): string { + const column = columns.find((entry) => entry.id === columnId); + return column ? resolveColumnLabel(column) : columnId; +} + +export function resolveDatabaseRowLabel( + snapshot: DatabaseReviewSnapshot | null, + rowId: string, +): string { + if (!snapshot) { + return rowId; + } + const rowIndex = findDatabaseReviewRowIndex(snapshot, rowId); + if (rowIndex === -1) { + return rowId; + } + + const columns = snapshot.columns; + const preferredColumnIds = [ + columns.find((column) => column.title.toLowerCase() === "name")?.id, + columns[0]?.id, + ].filter(Boolean) as string[]; + + for (const columnId of preferredColumnIds) { + const value = snapshot.rows[rowIndex]?.values[columnId]?.trim(); + if (value) { + return value; + } + } + + return `Row ${rowIndex + 1}`; +} + +export function resolveDatabaseActiveViewSnapshot( + snapshot: DatabaseReviewSnapshot | null, +): DatabaseViewState | null { + if (!snapshot) { + return null; + } + if (!snapshot.primaryViewId) { + return snapshot.views[0] ?? null; + } + return ( + snapshot.views.find((view) => view.id === snapshot.primaryViewId) ?? + snapshot.views[0] ?? + null + ); +} + +export function resolveViewLabel(view: DatabaseViewState | null | undefined): string | undefined { + if (!view) { + return undefined; + } + return view.title ?? view.id; +} + +export function formatColumnSchema( + column: TableColumnSchema | undefined, +): string | undefined { + if (!column) { + return undefined; + } + + const parts = [`${resolveColumnLabel(column)} [${column.type}]`]; + if (column.width != null) { + parts.push(`w:${column.width}`); + } + if (column.hidden) { + parts.push("hidden"); + } + if (column.pinned) { + parts.push(`pinned:${column.pinned}`); + } + return parts.join(" "); +} + +export function formatViewState( + view: DatabaseViewState | undefined, + columns: readonly TableColumnSchema[], +): string | undefined { + if (!view) { + return undefined; + } + + const parts = [`${resolveViewLabel(view)} [${view.type}]`]; + if (view.groupBy) { + parts.push(`group:${resolveDatabaseColumnLabel(columns, view.groupBy)}`); + } + if (view.visibleColumnIds && view.visibleColumnIds.length > 0) { + parts.push( + `visible:${view.visibleColumnIds + .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) + .join(", ")}`, + ); + } + return parts.join(" "); +} + +export function formatDatabaseValueKeys( + columns: readonly TableColumnSchema[], + values: Record, +): string | undefined { + const keys = Object.keys(values); + if (keys.length === 0) { + return undefined; + } + return keys + .map((key) => resolveDatabaseColumnLabel(columns, key)) + .join(", "); +} + +export function stringifyRecord( + value: Record, +): Record { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + stringifyDatabaseValue(entryValue), + ]), + ); +} + +export function stringifyDatabaseValue(value: unknown): string { + if (value == null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts new file mode 100644 index 0000000..1076d73 --- /dev/null +++ b/packages/extensions/ai/src/runtime/reviewArtifactsParts/reviewArtifactsPart4.ts @@ -0,0 +1,176 @@ +// @ts-nocheck +import type { Editor } from "@pen/types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { DocumentMutationPlan } from "../planTypes"; +import type { AITargetKind } from "../contracts"; +import { buildStructuralReviewItems, buildStructuredPreviewTargets, buildStructuralPreviewArtifacts, selectStructuralReviewItemPlan, removeStructuralReviewItemPlan, buildReviewItemsForPlan, serializeStructuredPreviewTargets, buildDatabaseReviewItems, createReviewItem, createReviewItemId } from "./reviewArtifactsPart1"; +import type { StructuralReviewItem, StructuralReviewComparisonRow, StructuralReviewBuildContext, DatabaseReviewSnapshot, StructuredPreviewDatabaseState, StructuredPreviewTargetState, VirtualReviewBlock } from "./reviewArtifactsPart1"; +import { selectPlanAtPath, removePlanAtPath, describeTextEditLabel, describeTextEditChangeKind, describeDatabaseStepLabel, describeDatabaseStepChangeKind, describeDatabaseStepSection, describeDatabaseStepSummary, describeDatabaseStepDetail, describeDatabaseStepPreview, describeDatabaseStepBefore, describeDatabaseStepAfter, describeDatabaseStepComparisonRows, stringifyReviewValue, readTextEditBefore, readBlockPropsPreview, readBlockTypePreview } from "./reviewArtifactsPart2"; +import { registerInsertedReviewBlock, describeInsertedBlockAfter, getDatabaseReviewSnapshot, cloneDatabaseReviewSnapshot, createDefaultDatabaseReviewSnapshot, applyDatabaseStepToReviewSnapshot, summarizeColumns, summarizeViews, findDatabaseReviewRowIndex, findColumnIndex, resolveColumnLabel, resolveDatabaseColumnLabel, resolveDatabaseRowLabel, resolveDatabaseActiveViewSnapshot, resolveViewLabel, formatColumnSchema, formatViewState, formatDatabaseValueKeys, stringifyRecord, stringifyDatabaseValue } from "./reviewArtifactsPart3"; + +export function buildColumnComparisonRows( + beforeColumns: readonly TableColumnSchema[], + afterColumns: readonly TableColumnSchema[], +): StructuralReviewComparisonRow[] | undefined { + const rows: StructuralReviewComparisonRow[] = []; + const beforeOrder = beforeColumns.map((column) => resolveColumnLabel(column)).join(", "); + const afterOrder = afterColumns.map((column) => resolveColumnLabel(column)).join(", "); + if (beforeOrder !== afterOrder) { + rows.push({ + label: "Order", + before: beforeOrder || undefined, + after: afterOrder || undefined, + changeKind: "updated", + section: "schema", + }); + } + + const beforeById = new Map(beforeColumns.map((column) => [column.id, column])); + const afterById = new Map(afterColumns.map((column) => [column.id, column])); + const allIds = [...new Set([...beforeById.keys(), ...afterById.keys()])]; + + for (const id of allIds) { + const beforeColumn = beforeById.get(id); + const afterColumn = afterById.get(id); + if (!beforeColumn && afterColumn) { + rows.push({ + label: `Added ${resolveColumnLabel(afterColumn)}`, + after: formatColumnSchema(afterColumn), + changeKind: "added", + section: "schema", + }); + continue; + } + if (beforeColumn && !afterColumn) { + rows.push({ + label: `Removed ${resolveColumnLabel(beforeColumn)}`, + before: formatColumnSchema(beforeColumn), + changeKind: "removed", + section: "schema", + }); + continue; + } + if (!beforeColumn || !afterColumn) { + continue; + } + if (!areColumnSchemasEqual(beforeColumn, afterColumn)) { + rows.push({ + label: resolveColumnLabel(afterColumn), + before: formatColumnSchema(beforeColumn), + after: formatColumnSchema(afterColumn), + changeKind: "updated", + section: "schema", + }); + } + } + + return rows.length > 0 ? rows : undefined; +} + +export function buildColumnSchemaComparisonRows( + beforeColumn: TableColumnSchema | undefined, + afterColumn: TableColumnSchema | undefined, +): StructuralReviewComparisonRow[] | undefined { + if (!beforeColumn && !afterColumn) { + return undefined; + } + + const rows: StructuralReviewComparisonRow[] = []; + const label = resolveColumnLabel(afterColumn ?? beforeColumn); + rows.push({ + label, + before: formatColumnSchema(beforeColumn), + after: formatColumnSchema(afterColumn), + changeKind: + beforeColumn == null ? "added" : afterColumn == null ? "removed" : "updated", + section: "schema", + }); + + return rows; +} + +export function buildViewComparisonRows( + beforeView: DatabaseViewState | undefined, + afterView: DatabaseViewState | undefined, + columns: readonly TableColumnSchema[], +): StructuralReviewComparisonRow[] | undefined { + if (!beforeView && !afterView) { + return undefined; + } + + const rows: StructuralReviewComparisonRow[] = [ + { + label: "View", + before: resolveViewLabel(beforeView), + after: resolveViewLabel(afterView), + changeKind: + beforeView == null ? "added" : afterView == null ? "removed" : "updated", + section: "view", + }, + { + label: "Type", + before: beforeView?.type, + after: afterView?.type, + changeKind: "updated", + section: "view", + }, + { + label: "Group by", + before: beforeView?.groupBy + ? resolveDatabaseColumnLabel(columns, beforeView.groupBy) + : undefined, + after: afterView?.groupBy + ? resolveDatabaseColumnLabel(columns, afterView.groupBy) + : undefined, + changeKind: "updated", + section: "view", + }, + { + label: "Visible columns", + before: beforeView?.visibleColumnIds?.length + ? beforeView.visibleColumnIds + .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) + .join(", ") + : undefined, + after: afterView?.visibleColumnIds?.length + ? afterView.visibleColumnIds + .map((columnId) => resolveDatabaseColumnLabel(columns, columnId)) + .join(", ") + : undefined, + changeKind: "updated", + section: "view", + }, + { + label: "Sort", + before: formatViewSort(beforeView, columns), + after: formatViewSort(afterView, columns), + changeKind: "updated", + section: "view", + }, + ]; + + const meaningfulRows = rows.filter((row) => row.before !== row.after); + return meaningfulRows.length > 0 ? meaningfulRows : undefined; +} + +export function areColumnSchemasEqual( + left: TableColumnSchema, + right: TableColumnSchema, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function formatViewSort( + view: DatabaseViewState | undefined, + columns: readonly TableColumnSchema[], +): string | undefined { + if (!view?.sort || view.sort.length === 0) { + return undefined; + } + return view.sort + .map( + (sortEntry) => + `${resolveDatabaseColumnLabel(columns, sortEntry.columnId)} ${sortEntry.direction}`, + ) + .join(", "); +} diff --git a/packages/extensions/ai/src/runtime/structuredIntent.ts b/packages/extensions/ai/src/runtime/structuredIntent.ts index 6aff64c..9b3d27b 100644 --- a/packages/extensions/ai/src/runtime/structuredIntent.ts +++ b/packages/extensions/ai/src/runtime/structuredIntent.ts @@ -1,897 +1,3 @@ -import type { AIWorkingSetEnvelope } from "../types"; -import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; -import type { AITargetKind } from "./contracts"; -import type { PlanConfidence } from "./planTypes"; - -export const STRUCTURED_INTENT_REQUEST_PREFIX = - "pen:structured-intent-request/v1"; - -export type StructuredIntentKind = - | "insert_block" - | "update_block" - | "move_block" - | "convert_block" - | "text_edit" - | "database" - | "review_bundle"; - -export type StructuredInsertPosition = - | "before_active" - | "after_active" - | "start" - | "end" - | { beforeBlockId: string } - | { afterBlockId: string } - | { parentId: string; index: number }; - -export interface StructuredTableColumn { - id?: string; - title: string; - type?: TableColumnSchema["type"]; -} - -export interface StructuredDatabaseRow { - rowId?: string; - values: Record; -} - -export interface StructuredDatabaseSeed { - columns?: StructuredTableColumn[]; - rows?: StructuredDatabaseRow[]; - views?: DatabaseViewState[]; - activeViewId?: string; -} - -export interface InsertBlockIntent { - kind: "insert_block"; - blockId?: string; - blockType: string; - position: StructuredInsertPosition; - props?: Record; - initialText?: string; - database?: StructuredDatabaseSeed; - confidence?: PlanConfidence; -} - -export interface UpdateBlockIntent { - kind: "update_block"; - blockId: string; - props: Record; - confidence?: PlanConfidence; -} - -export interface MoveBlockIntent { - kind: "move_block"; - blockId: string; - position: StructuredInsertPosition; - confidence?: PlanConfidence; -} - -export interface ConvertBlockIntent { - kind: "convert_block"; - blockId: string; - newType: string; - props?: Record; - confidence?: PlanConfidence; -} - -export interface TextEditIntent { - kind: "text_edit"; - target: { - blockId: string; - range?: { - startOffset: number; - endOffset: number; - }; - }; - operation: "replace" | "insert" | "append"; - text: string; - confidence?: PlanConfidence; -} - -export interface DatabaseIntent { - kind: "database"; - blockId: string; - columns?: StructuredTableColumn[]; - rows?: StructuredDatabaseRow[]; - views?: DatabaseViewState[]; - activeViewId?: string; - confidence?: PlanConfidence; -} - -export interface ReviewBundleIntent { - kind: "review_bundle"; - label: string; - reason: string; - changes: StructuredIntent[]; - confidence?: PlanConfidence; -} - -export type StructuredIntent = - | InsertBlockIntent - | UpdateBlockIntent - | MoveBlockIntent - | ConvertBlockIntent - | TextEditIntent - | DatabaseIntent - | ReviewBundleIntent; - -export interface StructuredIntentParseIssue { - path: string; - code: "missing-field" | "invalid-shape" | "invalid-kind"; - message: string; -} - -export interface StructuredIntentParseResult { - intent: StructuredIntent | null; - intentState: "drafted" | "validated" | "rejected"; - issues: StructuredIntentParseIssue[]; -} - -export interface StructuredIntentRequestEnvelope { - version: 1; - contract: "structured-intent"; - targetKind: AITargetKind; - prompt: string; - activeBlockId: string | null; - contextSummary: unknown; -} - -export interface StructuredIntentPromptConfig { - prompt: string; - targetKind: AITargetKind; - activeBlockId: string | null; - workingSet: AIWorkingSetEnvelope | null; -} - -export function getStructuredIntentOutputSchema( - targetKind: AITargetKind, -): Record { - const structuredColumnSchema = { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string" }, - title: { type: "string" }, - type: { type: "string" }, - }, - required: ["title"], - }, - }; - const databaseSeedSchema = { - type: "object", - properties: { - columns: structuredColumnSchema, - rows: { - type: "array", - items: { - type: "object", - properties: { - rowId: { type: "string" }, - values: { - type: "object", - additionalProperties: true, - }, - }, - required: ["values"], - }, - }, - views: { - type: "array", - items: { - type: "object", - additionalProperties: true, - }, - }, - activeViewId: { type: "string" }, - }, - }; - const positionSchema = { - anyOf: [ - { type: "string", enum: ["before_active", "after_active", "start", "end"] }, - { - type: "object", - properties: { - beforeBlockId: { type: "string" }, - }, - required: ["beforeBlockId"], - }, - { - type: "object", - properties: { - afterBlockId: { type: "string" }, - }, - required: ["afterBlockId"], - }, - { - type: "object", - properties: { - parentId: { type: "string" }, - index: { type: "number" }, - }, - required: ["parentId", "index"], - }, - ], - }; - const insertBlockSchema = { - type: "object", - properties: { - kind: { const: "insert_block" }, - blockId: { type: "string" }, - blockType: { - type: "string", - enum: - targetKind === "database" - ? ["database"] - : ["paragraph", "heading", "database"], - }, - position: positionSchema, - props: { - type: "object", - additionalProperties: true, - }, - initialText: { type: "string" }, - database: databaseSeedSchema, - confidence: { - anyOf: [ - { type: "number" }, - { - type: "object", - properties: { - score: { type: "number" }, - reason: { type: "string" }, - }, - }, - ], - }, - }, - required: ["kind", "blockType", "position"], - }; - const databaseSchema = { - type: "object", - properties: { - kind: { const: "database" }, - blockId: { type: "string" }, - columns: structuredColumnSchema, - rows: databaseSeedSchema.properties.rows, - views: databaseSeedSchema.properties.views, - activeViewId: { type: "string" }, - }, - required: ["kind", "blockId"], - }; - return { - type: "object", - anyOf: [ - insertBlockSchema, - databaseSchema, - { - type: "object", - properties: { - kind: { const: "review_bundle" }, - label: { type: "string" }, - reason: { type: "string" }, - changes: { - type: "array", - items: { - anyOf: [insertBlockSchema, databaseSchema], - }, - }, - }, - required: ["kind", "label", "reason", "changes"], - }, - ], - }; -} - -export function buildStructuredIntentRequestPrompt( - config: StructuredIntentPromptConfig, -): string { - const envelope: StructuredIntentRequestEnvelope = { - version: 1, - contract: "structured-intent", - targetKind: config.targetKind, - prompt: config.prompt, - activeBlockId: config.activeBlockId, - contextSummary: config.workingSet?.context ?? null, - }; - return [ - STRUCTURED_INTENT_REQUEST_PREFIX, - JSON.stringify(envelope), - ].join("\n"); -} - -export function parseStructuredIntentRequestPrompt( - value: string, -): StructuredIntentRequestEnvelope | null { - if (!value.startsWith(`${STRUCTURED_INTENT_REQUEST_PREFIX}\n`)) { - return null; - } - const jsonPayload = value - .slice(STRUCTURED_INTENT_REQUEST_PREFIX.length) - .trimStart(); - try { - const parsed = JSON.parse(jsonPayload) as StructuredIntentRequestEnvelope; - if ( - parsed?.version === 1 && - parsed.contract === "structured-intent" && - typeof parsed.prompt === "string" && - typeof parsed.targetKind === "string" - ) { - return parsed; - } - return null; - } catch { - return null; - } -} - -export function buildStructuredIntentModelPrompt( - request: StructuredIntentRequestEnvelope, -): string { - const allowedKinds = resolveAllowedStructuredIntentKinds(request.targetKind); - return [ - "Produce one structured Pen intent object.", - "Return valid JSON only and no markdown fences or prose.", - `Target kind: ${request.targetKind}`, - `Allowed top-level intent kinds: ${allowedKinds.join(", ")}`, - "", - "Use these intent rules:", - '- always include a top-level "kind" field', - '- use "review_bundle" with a "changes" array for mixed edits', - '- use "insert_block" for new blocks with position "after_active", "before_active", "start", or "end"', - '- when creating a new database, prefer one "insert_block" with embedded "database" seed data', - '- for database rows, use "rows" with "values" keyed by column id', - '- do not emit executor-level row/col operations', - "", - "Context summary:", - stringifyContextSummary(request.contextSummary), - "", - "User request:", - request.prompt, - ].join("\n"); -} - -export function parseStructuredIntentResult( - value: unknown, - targetKind: AITargetKind, -): StructuredIntentParseResult { - const issues: StructuredIntentParseIssue[] = []; - const intent = readStructuredIntent(value, "intent", issues, { - allowPartial: false, - targetKind, - }); - return { - intent, - intentState: intent ? "validated" : "rejected", - issues, - }; -} - -export function parseStructuredIntentPreview( - value: unknown, - targetKind: AITargetKind, -): StructuredIntentParseResult | null { - const issues: StructuredIntentParseIssue[] = []; - const intent = readStructuredIntent(value, "intent", issues, { - allowPartial: true, - targetKind, - }); - if (!intent) { - return null; - } - return { - intent, - intentState: issues.length === 0 ? "validated" : "drafted", - issues, - }; -} - -function resolveAllowedStructuredIntentKinds( - targetKind: AITargetKind, -): StructuredIntentKind[] { - if (targetKind === "database") { - return ["insert_block", "database", "review_bundle"]; - } - if (targetKind === "text") { - return ["text_edit"]; - } - return [ - "insert_block", - "update_block", - "move_block", - "convert_block", - "database", - "review_bundle", - ]; -} - -function stringifyContextSummary(value: unknown): string { - try { - return JSON.stringify(value ?? null); - } catch { - return "null"; - } -} - -function readStructuredIntent( - value: unknown, - path: string, - issues: StructuredIntentParseIssue[], - options: { - allowPartial: boolean; - targetKind: AITargetKind; - }, -): StructuredIntent | null { - if (options.targetKind === "table") { - issues.push({ - path, - code: "invalid-kind", - message: - "Structured table intents are not supported. Use the markdown authoring lane for tables.", - }); - return null; - } - const record = asRecord(value); - if (!record) { - issues.push({ - path, - code: "invalid-shape", - message: "Structured intent must be an object.", - }); - return null; - } - const kind = readNonEmptyString(record.kind); - if (!kind) { - issues.push({ - path: `${path}.kind`, - code: "missing-field", - message: "Structured intent kind is required.", - }); - return null; - } - switch (kind) { - case "insert_block": - return readInsertBlockIntent(record, path, issues, options.allowPartial); - case "update_block": - return readUpdateBlockIntent(record, path, issues, options.allowPartial); - case "move_block": - return readMoveBlockIntent(record, path, issues, options.allowPartial); - case "convert_block": - return readConvertBlockIntent(record, path, issues, options.allowPartial); - case "text_edit": - return readTextEditIntent(record, path, issues, options.allowPartial); - case "database": - return readDatabaseIntent(record, path, issues, options.allowPartial); - case "review_bundle": - return readReviewBundleIntent(record, path, issues, options); - default: - issues.push({ - path: `${path}.kind`, - code: "invalid-kind", - message: `Unsupported structured intent kind "${kind}".`, - }); - return null; - } -} - -function readInsertBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): InsertBlockIntent | null { - const blockType = readRequiredString( - record.blockType, - `${path}.blockType`, - issues, - allowPartial, - ); - const position = readStructuredPosition( - record.position, - `${path}.position`, - issues, - allowPartial, - ); - if (!blockType || !position) { - return null; - } - if (blockType === "table") { - if (!allowPartial) { - issues.push({ - path: `${path}.blockType`, - code: "invalid-kind", - message: - "Structured table intents are not supported. Use the markdown authoring lane for tables.", - }); - } - return null; - } - return { - kind: "insert_block", - blockId: readNonEmptyString(record.blockId) ?? undefined, - blockType, - position, - props: asRecord(record.props) ?? undefined, - initialText: readNonEmptyString(record.initialText) ?? undefined, - database: readStructuredDatabaseSeed(record.database), - confidence: readConfidence(record.confidence), - }; -} - -function readUpdateBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): UpdateBlockIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - const props = asRecord(record.props); - if (!blockId || !props) { - if (!props && !allowPartial) { - issues.push({ - path: `${path}.props`, - code: "invalid-shape", - message: "Block update props must be an object.", - }); - } - return null; - } - return { - kind: "update_block", - blockId, - props, - confidence: readConfidence(record.confidence), - }; -} - -function readMoveBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): MoveBlockIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - const position = readStructuredPosition( - record.position, - `${path}.position`, - issues, - allowPartial, - ); - if (!blockId || !position) { - return null; - } - return { - kind: "move_block", - blockId, - position, - confidence: readConfidence(record.confidence), - }; -} - -function readConvertBlockIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): ConvertBlockIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - const newType = readRequiredString( - record.newType, - `${path}.newType`, - issues, - allowPartial, - ); - if (!blockId || !newType) { - return null; - } - return { - kind: "convert_block", - blockId, - newType, - props: asRecord(record.props) ?? undefined, - confidence: readConfidence(record.confidence), - }; -} - -function readTextEditIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): TextEditIntent | null { - const target = asRecord(record.target); - const blockId = readRequiredString( - target?.blockId, - `${path}.target.blockId`, - issues, - allowPartial, - ); - const operation = readRequiredString( - record.operation, - `${path}.operation`, - issues, - allowPartial, - ) as TextEditIntent["operation"] | null; - const text = readRequiredString( - record.text, - `${path}.text`, - issues, - allowPartial, - ); - if (!blockId || !operation || !text) { - return null; - } - const rangeRecord = asRecord(target?.range); - return { - kind: "text_edit", - target: { - blockId, - range: - rangeRecord && - isFiniteNumber(rangeRecord.startOffset) && - isFiniteNumber(rangeRecord.endOffset) - ? { - startOffset: rangeRecord.startOffset, - endOffset: rangeRecord.endOffset, - } - : undefined, - }, - operation, - text, - confidence: readConfidence(record.confidence), - }; -} - -function readDatabaseIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): DatabaseIntent | null { - const blockId = readRequiredString( - record.blockId, - `${path}.blockId`, - issues, - allowPartial, - ); - if (!blockId) { - return null; - } - return { - kind: "database", - blockId, - columns: readStructuredColumns(record.columns), - rows: readStructuredDatabaseRows(record.rows), - views: Array.isArray(record.views) - ? (record.views.filter((view): view is DatabaseViewState => { - return !!view && typeof view === "object"; - }) as DatabaseViewState[]) - : undefined, - activeViewId: readNonEmptyString(record.activeViewId) ?? undefined, - confidence: readConfidence(record.confidence), - }; -} - -function readReviewBundleIntent( - record: Record, - path: string, - issues: StructuredIntentParseIssue[], - options: { allowPartial: boolean; targetKind: AITargetKind }, -): ReviewBundleIntent | null { - const changes = Array.isArray(record.changes) - ? record.changes - .map((entry, index) => - readStructuredIntent(entry, `${path}.changes[${index}]`, issues, options), - ) - .filter((entry): entry is StructuredIntent => entry !== null) - : []; - if (changes.length === 0 && !options.allowPartial) { - issues.push({ - path: `${path}.changes`, - code: "missing-field", - message: "Review bundle changes are required.", - }); - return null; - } - return { - kind: "review_bundle", - label: - readNonEmptyString(record.label) ?? - (options.allowPartial ? "Streaming structured changes" : ""), - reason: - readNonEmptyString(record.reason) ?? - (options.allowPartial ? "Streaming structured preview." : ""), - changes, - confidence: readConfidence(record.confidence), - }; -} - -function readStructuredPosition( - value: unknown, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): StructuredInsertPosition | null { - if ( - value === "before_active" || - value === "after_active" || - value === "start" || - value === "end" - ) { - return value; - } - const record = asRecord(value); - if (!record) { - if (!allowPartial) { - issues.push({ - path, - code: "invalid-shape", - message: "Structured position is required.", - }); - } - return null; - } - const beforeBlockId = readNonEmptyString(record.beforeBlockId); - if (beforeBlockId) { - return { beforeBlockId }; - } - const afterBlockId = readNonEmptyString(record.afterBlockId); - if (afterBlockId) { - return { afterBlockId }; - } - const parentId = readNonEmptyString(record.parentId); - if (parentId && isFiniteNumber(record.index)) { - return { parentId, index: record.index }; - } - if (!allowPartial) { - issues.push({ - path, - code: "invalid-shape", - message: "Structured position is invalid.", - }); - } - return null; -} - -function readStructuredColumns( - value: unknown, -): StructuredTableColumn[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const columns = value.flatMap((column) => { - const record = asRecord(column); - const title = - readNonEmptyString(record?.title) ?? readNonEmptyString(record?.header); - if (!title) { - return []; - } - const normalizedColumn: StructuredTableColumn = { - id: readNonEmptyString(record?.id) ?? undefined, - title, - type: - (readNonEmptyString(record?.type) as TableColumnSchema["type"] | null) ?? - "text", - }; - return [normalizedColumn]; - }); - return columns.length > 0 ? columns : undefined; -} - -function readStructuredDatabaseRows( - value: unknown, -): StructuredDatabaseRow[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const rows = value.flatMap((row) => { - const record = asRecord(row); - const values = asRecord(record?.values); - if (!values) { - return []; - } - const normalizedRow: StructuredDatabaseRow = { - rowId: readNonEmptyString(record?.rowId) ?? undefined, - values, - }; - return [normalizedRow]; - }); - return rows.length > 0 ? rows : undefined; -} - -function readStructuredDatabaseSeed( - value: unknown, -): StructuredDatabaseSeed | undefined { - const record = asRecord(value); - if (!record) { - return undefined; - } - const columns = readStructuredColumns(record.columns); - const rows = readStructuredDatabaseRows(record.rows); - const views = Array.isArray(record.views) - ? (record.views.filter((view): view is DatabaseViewState => { - return !!view && typeof view === "object"; - }) as DatabaseViewState[]) - : undefined; - const activeViewId = readNonEmptyString(record.activeViewId) ?? undefined; - if (!columns && !rows && !views && !activeViewId) { - return undefined; - } - return { - columns, - rows, - views, - activeViewId, - }; -} - -function readConfidence(value: unknown): PlanConfidence | undefined { - if (value == null) { - return undefined; - } - if (isFiniteNumber(value)) { - return { score: value }; - } - const record = asRecord(value); - if (!record) { - return undefined; - } - const confidence: PlanConfidence = {}; - if (isFiniteNumber(record.score)) { - confidence.score = record.score; - } - if (readNonEmptyString(record.reason)) { - confidence.reason = record.reason as string; - } - return Object.keys(confidence).length > 0 ? confidence : undefined; -} - -function readRequiredString( - value: unknown, - path: string, - issues: StructuredIntentParseIssue[], - allowPartial: boolean, -): string | null { - const stringValue = readNonEmptyString(value); - if (stringValue) { - return stringValue; - } - if (!allowPartial) { - issues.push({ - path, - code: "missing-field", - message: "Field is required.", - }); - } - return null; -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} +export { STRUCTURED_INTENT_REQUEST_PREFIX, getStructuredIntentOutputSchema, buildStructuredIntentRequestPrompt, parseStructuredIntentRequestPrompt, buildStructuredIntentModelPrompt } from "./structuredIntentParts/structuredIntentPart1"; +export type { StructuredIntentKind, StructuredInsertPosition, StructuredTableColumn, StructuredDatabaseRow, StructuredDatabaseSeed, InsertBlockIntent, UpdateBlockIntent, MoveBlockIntent, ConvertBlockIntent, TextEditIntent, DatabaseIntent, ReviewBundleIntent, StructuredIntent, StructuredIntentParseIssue, StructuredIntentParseResult, StructuredIntentRequestEnvelope, StructuredIntentPromptConfig } from "./structuredIntentParts/structuredIntentPart1"; +export { parseStructuredIntentResult, parseStructuredIntentPreview } from "./structuredIntentParts/structuredIntentPart2"; diff --git a/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts new file mode 100644 index 0000000..ffcd888 --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart1.ts @@ -0,0 +1,356 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import type { PlanConfidence } from "../planTypes"; +import { parseStructuredIntentResult, parseStructuredIntentPreview, resolveAllowedStructuredIntentKinds, stringifyContextSummary, readStructuredIntent, readInsertBlockIntent, readUpdateBlockIntent, readMoveBlockIntent, readConvertBlockIntent, readTextEditIntent, readDatabaseIntent } from "./structuredIntentPart2"; +import { readReviewBundleIntent, readStructuredPosition, readStructuredColumns, readStructuredDatabaseRows, readStructuredDatabaseSeed, readConfidence, readRequiredString, asRecord, readNonEmptyString, isFiniteNumber } from "./structuredIntentPart3"; + +export const STRUCTURED_INTENT_REQUEST_PREFIX = + "pen:structured-intent-request/v1"; + +export type StructuredIntentKind = + | "insert_block" + | "update_block" + | "move_block" + | "convert_block" + | "text_edit" + | "database" + | "review_bundle"; + +export type StructuredInsertPosition = + | "before_active" + | "after_active" + | "start" + | "end" + | { beforeBlockId: string } + | { afterBlockId: string } + | { parentId: string; index: number }; + +export interface StructuredTableColumn { + id?: string; + title: string; + type?: TableColumnSchema["type"]; +} + +export interface StructuredDatabaseRow { + rowId?: string; + values: Record; +} + +export interface StructuredDatabaseSeed { + columns?: StructuredTableColumn[]; + rows?: StructuredDatabaseRow[]; + views?: DatabaseViewState[]; + activeViewId?: string; +} + +export interface InsertBlockIntent { + kind: "insert_block"; + blockId?: string; + blockType: string; + position: StructuredInsertPosition; + props?: Record; + initialText?: string; + database?: StructuredDatabaseSeed; + confidence?: PlanConfidence; +} + +export interface UpdateBlockIntent { + kind: "update_block"; + blockId: string; + props: Record; + confidence?: PlanConfidence; +} + +export interface MoveBlockIntent { + kind: "move_block"; + blockId: string; + position: StructuredInsertPosition; + confidence?: PlanConfidence; +} + +export interface ConvertBlockIntent { + kind: "convert_block"; + blockId: string; + newType: string; + props?: Record; + confidence?: PlanConfidence; +} + +export interface TextEditIntent { + kind: "text_edit"; + target: { + blockId: string; + range?: { + startOffset: number; + endOffset: number; + }; + }; + operation: "replace" | "insert" | "append"; + text: string; + confidence?: PlanConfidence; +} + +export interface DatabaseIntent { + kind: "database"; + blockId: string; + columns?: StructuredTableColumn[]; + rows?: StructuredDatabaseRow[]; + views?: DatabaseViewState[]; + activeViewId?: string; + confidence?: PlanConfidence; +} + +export interface ReviewBundleIntent { + kind: "review_bundle"; + label: string; + reason: string; + changes: StructuredIntent[]; + confidence?: PlanConfidence; +} + +export type StructuredIntent = + | InsertBlockIntent + | UpdateBlockIntent + | MoveBlockIntent + | ConvertBlockIntent + | TextEditIntent + | DatabaseIntent + | ReviewBundleIntent; + +export interface StructuredIntentParseIssue { + path: string; + code: "missing-field" | "invalid-shape" | "invalid-kind"; + message: string; +} + +export interface StructuredIntentParseResult { + intent: StructuredIntent | null; + intentState: "drafted" | "validated" | "rejected"; + issues: StructuredIntentParseIssue[]; +} + +export interface StructuredIntentRequestEnvelope { + version: 1; + contract: "structured-intent"; + targetKind: AITargetKind; + prompt: string; + activeBlockId: string | null; + contextSummary: unknown; +} + +export interface StructuredIntentPromptConfig { + prompt: string; + targetKind: AITargetKind; + activeBlockId: string | null; + workingSet: AIWorkingSetEnvelope | null; +} + +export function getStructuredIntentOutputSchema( + targetKind: AITargetKind, +): Record { + const structuredColumnSchema = { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + type: { type: "string" }, + }, + required: ["title"], + }, + }; + const databaseSeedSchema = { + type: "object", + properties: { + columns: structuredColumnSchema, + rows: { + type: "array", + items: { + type: "object", + properties: { + rowId: { type: "string" }, + values: { + type: "object", + additionalProperties: true, + }, + }, + required: ["values"], + }, + }, + views: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + }, + activeViewId: { type: "string" }, + }, + }; + const positionSchema = { + anyOf: [ + { type: "string", enum: ["before_active", "after_active", "start", "end"] }, + { + type: "object", + properties: { + beforeBlockId: { type: "string" }, + }, + required: ["beforeBlockId"], + }, + { + type: "object", + properties: { + afterBlockId: { type: "string" }, + }, + required: ["afterBlockId"], + }, + { + type: "object", + properties: { + parentId: { type: "string" }, + index: { type: "number" }, + }, + required: ["parentId", "index"], + }, + ], + }; + const insertBlockSchema = { + type: "object", + properties: { + kind: { const: "insert_block" }, + blockId: { type: "string" }, + blockType: { + type: "string", + enum: + targetKind === "database" + ? ["database"] + : ["paragraph", "heading", "database"], + }, + position: positionSchema, + props: { + type: "object", + additionalProperties: true, + }, + initialText: { type: "string" }, + database: databaseSeedSchema, + confidence: { + anyOf: [ + { type: "number" }, + { + type: "object", + properties: { + score: { type: "number" }, + reason: { type: "string" }, + }, + }, + ], + }, + }, + required: ["kind", "blockType", "position"], + }; + const databaseSchema = { + type: "object", + properties: { + kind: { const: "database" }, + blockId: { type: "string" }, + columns: structuredColumnSchema, + rows: databaseSeedSchema.properties.rows, + views: databaseSeedSchema.properties.views, + activeViewId: { type: "string" }, + }, + required: ["kind", "blockId"], + }; + return { + type: "object", + anyOf: [ + insertBlockSchema, + databaseSchema, + { + type: "object", + properties: { + kind: { const: "review_bundle" }, + label: { type: "string" }, + reason: { type: "string" }, + changes: { + type: "array", + items: { + anyOf: [insertBlockSchema, databaseSchema], + }, + }, + }, + required: ["kind", "label", "reason", "changes"], + }, + ], + }; +} + +export function buildStructuredIntentRequestPrompt( + config: StructuredIntentPromptConfig, +): string { + const envelope: StructuredIntentRequestEnvelope = { + version: 1, + contract: "structured-intent", + targetKind: config.targetKind, + prompt: config.prompt, + activeBlockId: config.activeBlockId, + contextSummary: config.workingSet?.context ?? null, + }; + return [ + STRUCTURED_INTENT_REQUEST_PREFIX, + JSON.stringify(envelope), + ].join("\n"); +} + +export function parseStructuredIntentRequestPrompt( + value: string, +): StructuredIntentRequestEnvelope | null { + if (!value.startsWith(`${STRUCTURED_INTENT_REQUEST_PREFIX}\n`)) { + return null; + } + const jsonPayload = value + .slice(STRUCTURED_INTENT_REQUEST_PREFIX.length) + .trimStart(); + try { + const parsed = JSON.parse(jsonPayload) as StructuredIntentRequestEnvelope; + if ( + parsed?.version === 1 && + parsed.contract === "structured-intent" && + typeof parsed.prompt === "string" && + typeof parsed.targetKind === "string" + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +export function buildStructuredIntentModelPrompt( + request: StructuredIntentRequestEnvelope, +): string { + const allowedKinds = resolveAllowedStructuredIntentKinds(request.targetKind); + return [ + "Produce one structured Pen intent object.", + "Return valid JSON only and no markdown fences or prose.", + `Target kind: ${request.targetKind}`, + `Allowed top-level intent kinds: ${allowedKinds.join(", ")}`, + "", + "Use these intent rules:", + '- always include a top-level "kind" field', + '- use "review_bundle" with a "changes" array for mixed edits', + '- use "insert_block" for new blocks with position "after_active", "before_active", "start", or "end"', + '- when creating a new database, prefer one "insert_block" with embedded "database" seed data', + '- for database rows, use "rows" with "values" keyed by column id', + '- do not emit executor-level row/col operations', + "", + "Context summary:", + stringifyContextSummary(request.contextSummary), + "", + "User request:", + request.prompt, + ].join("\n"); +} diff --git a/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts new file mode 100644 index 0000000..cefd081 --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart2.ts @@ -0,0 +1,344 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import type { PlanConfidence } from "../planTypes"; +import { STRUCTURED_INTENT_REQUEST_PREFIX, getStructuredIntentOutputSchema, buildStructuredIntentRequestPrompt, parseStructuredIntentRequestPrompt, buildStructuredIntentModelPrompt } from "./structuredIntentPart1"; +import type { StructuredIntentKind, StructuredInsertPosition, StructuredTableColumn, StructuredDatabaseRow, StructuredDatabaseSeed, InsertBlockIntent, UpdateBlockIntent, MoveBlockIntent, ConvertBlockIntent, TextEditIntent, DatabaseIntent, ReviewBundleIntent, StructuredIntent, StructuredIntentParseIssue, StructuredIntentParseResult, StructuredIntentRequestEnvelope, StructuredIntentPromptConfig } from "./structuredIntentPart1"; +import { readReviewBundleIntent, readStructuredPosition, readStructuredColumns, readStructuredDatabaseRows, readStructuredDatabaseSeed, readConfidence, readRequiredString, asRecord, readNonEmptyString, isFiniteNumber } from "./structuredIntentPart3"; + +export function parseStructuredIntentResult( + value: unknown, + targetKind: AITargetKind, +): StructuredIntentParseResult { + const issues: StructuredIntentParseIssue[] = []; + const intent = readStructuredIntent(value, "intent", issues, { + allowPartial: false, + targetKind, + }); + return { + intent, + intentState: intent ? "validated" : "rejected", + issues, + }; +} + +export function parseStructuredIntentPreview( + value: unknown, + targetKind: AITargetKind, +): StructuredIntentParseResult | null { + const issues: StructuredIntentParseIssue[] = []; + const intent = readStructuredIntent(value, "intent", issues, { + allowPartial: true, + targetKind, + }); + if (!intent) { + return null; + } + return { + intent, + intentState: issues.length === 0 ? "validated" : "drafted", + issues, + }; +} + +export function resolveAllowedStructuredIntentKinds( + targetKind: AITargetKind, +): StructuredIntentKind[] { + if (targetKind === "database") { + return ["insert_block", "database", "review_bundle"]; + } + if (targetKind === "text") { + return ["text_edit"]; + } + return [ + "insert_block", + "update_block", + "move_block", + "convert_block", + "database", + "review_bundle", + ]; +} + +export function stringifyContextSummary(value: unknown): string { + try { + return JSON.stringify(value ?? null); + } catch { + return "null"; + } +} + +export function readStructuredIntent( + value: unknown, + path: string, + issues: StructuredIntentParseIssue[], + options: { + allowPartial: boolean; + targetKind: AITargetKind; + }, +): StructuredIntent | null { + if (options.targetKind === "table") { + issues.push({ + path, + code: "invalid-kind", + message: + "Structured table intents are not supported. Use the markdown authoring lane for tables.", + }); + return null; + } + const record = asRecord(value); + if (!record) { + issues.push({ + path, + code: "invalid-shape", + message: "Structured intent must be an object.", + }); + return null; + } + const kind = readNonEmptyString(record.kind); + if (!kind) { + issues.push({ + path: `${path}.kind`, + code: "missing-field", + message: "Structured intent kind is required.", + }); + return null; + } + switch (kind) { + case "insert_block": + return readInsertBlockIntent(record, path, issues, options.allowPartial); + case "update_block": + return readUpdateBlockIntent(record, path, issues, options.allowPartial); + case "move_block": + return readMoveBlockIntent(record, path, issues, options.allowPartial); + case "convert_block": + return readConvertBlockIntent(record, path, issues, options.allowPartial); + case "text_edit": + return readTextEditIntent(record, path, issues, options.allowPartial); + case "database": + return readDatabaseIntent(record, path, issues, options.allowPartial); + case "review_bundle": + return readReviewBundleIntent(record, path, issues, options); + default: + issues.push({ + path: `${path}.kind`, + code: "invalid-kind", + message: `Unsupported structured intent kind "${kind}".`, + }); + return null; + } +} + +export function readInsertBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): InsertBlockIntent | null { + const blockType = readRequiredString( + record.blockType, + `${path}.blockType`, + issues, + allowPartial, + ); + const position = readStructuredPosition( + record.position, + `${path}.position`, + issues, + allowPartial, + ); + if (!blockType || !position) { + return null; + } + if (blockType === "table") { + if (!allowPartial) { + issues.push({ + path: `${path}.blockType`, + code: "invalid-kind", + message: + "Structured table intents are not supported. Use the markdown authoring lane for tables.", + }); + } + return null; + } + return { + kind: "insert_block", + blockId: readNonEmptyString(record.blockId) ?? undefined, + blockType, + position, + props: asRecord(record.props) ?? undefined, + initialText: readNonEmptyString(record.initialText) ?? undefined, + database: readStructuredDatabaseSeed(record.database), + confidence: readConfidence(record.confidence), + }; +} + +export function readUpdateBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): UpdateBlockIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + const props = asRecord(record.props); + if (!blockId || !props) { + if (!props && !allowPartial) { + issues.push({ + path: `${path}.props`, + code: "invalid-shape", + message: "Block update props must be an object.", + }); + } + return null; + } + return { + kind: "update_block", + blockId, + props, + confidence: readConfidence(record.confidence), + }; +} + +export function readMoveBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): MoveBlockIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + const position = readStructuredPosition( + record.position, + `${path}.position`, + issues, + allowPartial, + ); + if (!blockId || !position) { + return null; + } + return { + kind: "move_block", + blockId, + position, + confidence: readConfidence(record.confidence), + }; +} + +export function readConvertBlockIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): ConvertBlockIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + const newType = readRequiredString( + record.newType, + `${path}.newType`, + issues, + allowPartial, + ); + if (!blockId || !newType) { + return null; + } + return { + kind: "convert_block", + blockId, + newType, + props: asRecord(record.props) ?? undefined, + confidence: readConfidence(record.confidence), + }; +} + +export function readTextEditIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): TextEditIntent | null { + const target = asRecord(record.target); + const blockId = readRequiredString( + target?.blockId, + `${path}.target.blockId`, + issues, + allowPartial, + ); + const operation = readRequiredString( + record.operation, + `${path}.operation`, + issues, + allowPartial, + ) as TextEditIntent["operation"] | null; + const text = readRequiredString( + record.text, + `${path}.text`, + issues, + allowPartial, + ); + if (!blockId || !operation || !text) { + return null; + } + const rangeRecord = asRecord(target?.range); + return { + kind: "text_edit", + target: { + blockId, + range: + rangeRecord && + isFiniteNumber(rangeRecord.startOffset) && + isFiniteNumber(rangeRecord.endOffset) + ? { + startOffset: rangeRecord.startOffset, + endOffset: rangeRecord.endOffset, + } + : undefined, + }, + operation, + text, + confidence: readConfidence(record.confidence), + }; +} + +export function readDatabaseIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): DatabaseIntent | null { + const blockId = readRequiredString( + record.blockId, + `${path}.blockId`, + issues, + allowPartial, + ); + if (!blockId) { + return null; + } + return { + kind: "database", + blockId, + columns: readStructuredColumns(record.columns), + rows: readStructuredDatabaseRows(record.rows), + views: Array.isArray(record.views) + ? (record.views.filter((view): view is DatabaseViewState => { + return !!view && typeof view === "object"; + }) as DatabaseViewState[]) + : undefined, + activeViewId: readNonEmptyString(record.activeViewId) ?? undefined, + confidence: readConfidence(record.confidence), + }; +} diff --git a/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts new file mode 100644 index 0000000..c1634dc --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredIntentParts/structuredIntentPart3.ts @@ -0,0 +1,216 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { AITargetKind } from "../contracts"; +import type { PlanConfidence } from "../planTypes"; +import { STRUCTURED_INTENT_REQUEST_PREFIX, getStructuredIntentOutputSchema, buildStructuredIntentRequestPrompt, parseStructuredIntentRequestPrompt, buildStructuredIntentModelPrompt } from "./structuredIntentPart1"; +import type { StructuredIntentKind, StructuredInsertPosition, StructuredTableColumn, StructuredDatabaseRow, StructuredDatabaseSeed, InsertBlockIntent, UpdateBlockIntent, MoveBlockIntent, ConvertBlockIntent, TextEditIntent, DatabaseIntent, ReviewBundleIntent, StructuredIntent, StructuredIntentParseIssue, StructuredIntentParseResult, StructuredIntentRequestEnvelope, StructuredIntentPromptConfig } from "./structuredIntentPart1"; +import { parseStructuredIntentResult, parseStructuredIntentPreview, resolveAllowedStructuredIntentKinds, stringifyContextSummary, readStructuredIntent, readInsertBlockIntent, readUpdateBlockIntent, readMoveBlockIntent, readConvertBlockIntent, readTextEditIntent, readDatabaseIntent } from "./structuredIntentPart2"; + +export function readReviewBundleIntent( + record: Record, + path: string, + issues: StructuredIntentParseIssue[], + options: { allowPartial: boolean; targetKind: AITargetKind }, +): ReviewBundleIntent | null { + const changes = Array.isArray(record.changes) + ? record.changes + .map((entry, index) => + readStructuredIntent(entry, `${path}.changes[${index}]`, issues, options), + ) + .filter((entry): entry is StructuredIntent => entry !== null) + : []; + if (changes.length === 0 && !options.allowPartial) { + issues.push({ + path: `${path}.changes`, + code: "missing-field", + message: "Review bundle changes are required.", + }); + return null; + } + return { + kind: "review_bundle", + label: + readNonEmptyString(record.label) ?? + (options.allowPartial ? "Streaming structured changes" : ""), + reason: + readNonEmptyString(record.reason) ?? + (options.allowPartial ? "Streaming structured preview." : ""), + changes, + confidence: readConfidence(record.confidence), + }; +} + +export function readStructuredPosition( + value: unknown, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): StructuredInsertPosition | null { + if ( + value === "before_active" || + value === "after_active" || + value === "start" || + value === "end" + ) { + return value; + } + const record = asRecord(value); + if (!record) { + if (!allowPartial) { + issues.push({ + path, + code: "invalid-shape", + message: "Structured position is required.", + }); + } + return null; + } + const beforeBlockId = readNonEmptyString(record.beforeBlockId); + if (beforeBlockId) { + return { beforeBlockId }; + } + const afterBlockId = readNonEmptyString(record.afterBlockId); + if (afterBlockId) { + return { afterBlockId }; + } + const parentId = readNonEmptyString(record.parentId); + if (parentId && isFiniteNumber(record.index)) { + return { parentId, index: record.index }; + } + if (!allowPartial) { + issues.push({ + path, + code: "invalid-shape", + message: "Structured position is invalid.", + }); + } + return null; +} + +export function readStructuredColumns( + value: unknown, +): StructuredTableColumn[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const columns = value.flatMap((column) => { + const record = asRecord(column); + const title = + readNonEmptyString(record?.title) ?? readNonEmptyString(record?.header); + if (!title) { + return []; + } + const normalizedColumn: StructuredTableColumn = { + id: readNonEmptyString(record?.id) ?? undefined, + title, + type: + (readNonEmptyString(record?.type) as TableColumnSchema["type"] | null) ?? + "text", + }; + return [normalizedColumn]; + }); + return columns.length > 0 ? columns : undefined; +} + +export function readStructuredDatabaseRows( + value: unknown, +): StructuredDatabaseRow[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const rows = value.flatMap((row) => { + const record = asRecord(row); + const values = asRecord(record?.values); + if (!values) { + return []; + } + const normalizedRow: StructuredDatabaseRow = { + rowId: readNonEmptyString(record?.rowId) ?? undefined, + values, + }; + return [normalizedRow]; + }); + return rows.length > 0 ? rows : undefined; +} + +export function readStructuredDatabaseSeed( + value: unknown, +): StructuredDatabaseSeed | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const columns = readStructuredColumns(record.columns); + const rows = readStructuredDatabaseRows(record.rows); + const views = Array.isArray(record.views) + ? (record.views.filter((view): view is DatabaseViewState => { + return !!view && typeof view === "object"; + }) as DatabaseViewState[]) + : undefined; + const activeViewId = readNonEmptyString(record.activeViewId) ?? undefined; + if (!columns && !rows && !views && !activeViewId) { + return undefined; + } + return { + columns, + rows, + views, + activeViewId, + }; +} + +export function readConfidence(value: unknown): PlanConfidence | undefined { + if (value == null) { + return undefined; + } + if (isFiniteNumber(value)) { + return { score: value }; + } + const record = asRecord(value); + if (!record) { + return undefined; + } + const confidence: PlanConfidence = {}; + if (isFiniteNumber(record.score)) { + confidence.score = record.score; + } + if (readNonEmptyString(record.reason)) { + confidence.reason = record.reason as string; + } + return Object.keys(confidence).length > 0 ? confidence : undefined; +} + +export function readRequiredString( + value: unknown, + path: string, + issues: StructuredIntentParseIssue[], + allowPartial: boolean, +): string | null { + const stringValue = readNonEmptyString(value); + if (stringValue) { + return stringValue; + } + if (!allowPartial) { + issues.push({ + path, + code: "missing-field", + message: "Field is required.", + }); + } + return null; +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/packages/extensions/ai/src/runtime/structuredPlanner.ts b/packages/extensions/ai/src/runtime/structuredPlanner.ts index 1c6cf6a..b636a68 100644 --- a/packages/extensions/ai/src/runtime/structuredPlanner.ts +++ b/packages/extensions/ai/src/runtime/structuredPlanner.ts @@ -1,699 +1,2 @@ -import type { AIWorkingSetEnvelope } from "../types"; -import type { - AIExecutionMode, - AIPlannerMode, - AITargetKind, - AIMutationMode, -} from "./contracts"; -import type { DocumentMutationPlan } from "./planTypes"; -import { - validateDocumentMutationPlanShape, - type PlanValidationIssue, -} from "./planValidation"; - -export interface StructuredPlannerConfig { - prompt: string; - targetKind: AITargetKind; - workingSet: AIWorkingSetEnvelope | null; -} - -export interface StructuredPlannerParseResult { - plan: DocumentMutationPlan | null; - planState: "drafted" | "validated" | "rejected"; - issues: PlanValidationIssue[]; -} - -export function resolvePlannerMode(options: { - targetKind: AITargetKind; - intent: "rewrite" | "continue" | "local-edit" | "structural" | "search" | "review" | "unknown"; - target: "selection" | "block"; -}): AIPlannerMode { - if (options.target === "selection") { - return "text"; - } - if (options.targetKind === "database") { - return "structured"; - } - if (options.targetKind === "table") { - return "text"; - } - if (options.intent === "structural" || options.intent === "review") { - return "structured"; - } - return "text"; -} - -export function resolveGenerationTargetKind(options: { - target: "selection" | "block"; - blockType: string | null; - workingSet: AIWorkingSetEnvelope | null; -}): AITargetKind { - if (options.target === "selection") { - return "text"; - } - - const structuredKind = readStructuredTargetKind(options.workingSet); - if (structuredKind) { - return structuredKind; - } - - if (options.blockType === "table") { - return "table"; - } - if (options.blockType === "database") { - return "database"; - } - return "block"; -} - -export function buildPlannerPrompt( - config: StructuredPlannerConfig, -): string { - const allowedPlanKinds = resolveAllowedPlanKinds(config.targetKind); - const targetSummary = buildTargetSummary(config.workingSet); - - return [ - "Produce a structured Pen document mutation plan.", - "Return exactly one JSON object and no markdown fences or prose.", - `Target kind: ${config.targetKind}`, - `Allowed top-level plan kinds: ${allowedPlanKinds.join(", ")}`, - "", - "Use these JSON-shape rules:", - '- include a top-level "kind" string', - "- include all required object properties for the chosen plan kind", - "- use arrays only for ordered step lists", - "- use nested objects for target, position, confidence, and patch payloads", - "- for review bundles, return a review_bundle with nested plans", - "- if later plans need to target a newly inserted block, include blockId on the block_insert plan and reuse that same blockId in later plans", - "- for new databases, prefer a review_bundle that inserts the block first and then applies database_edit steps to that inserted block", - "", - "Context summary:", - targetSummary, - "", - "User request:", - config.prompt, - ].join("\n"); -} - -export function parseStructuredPlanResult( - value: string, - targetKind: AITargetKind, -): StructuredPlannerParseResult { - if (targetKind === "table") { - return { - plan: null, - planState: "rejected", - issues: [ - { - path: "plan", - code: "invalid-kind", - severity: "error", - message: - "Structured table plans are not supported. Use the markdown authoring lane for tables.", - }, - ], - }; - } - const jsonPayload = extractJsonObject(value); - if (!jsonPayload) { - return { - plan: null, - planState: "rejected", - issues: [ - { - path: "plan", - code: "invalid-shape", - severity: "error", - message: "Planner response did not contain a JSON object.", - }, - ], - }; - } - - try { - const parsed = normalizeStructuredPlanCandidate( - JSON.parse(jsonPayload) as unknown, - targetKind, - ); - const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); - return { - plan: validation.valid ? (parsed as DocumentMutationPlan) : null, - planState: validation.valid ? "validated" : "rejected", - issues: validation.issues, - }; - } catch (error) { - return { - plan: null, - planState: "rejected", - issues: [ - { - path: "plan", - code: "invalid-shape", - severity: "error", - message: - error instanceof Error - ? error.message - : "Planner response could not be parsed as JSON.", - }, - ], - }; - } -} - -export function parseStructuredPlanPreview( - value: string, - targetKind: AITargetKind, -): StructuredPlannerParseResult | null { - if (targetKind === "table") { - return null; - } - const jsonPayload = extractJsonObject(value); - if (jsonPayload) { - try { - const parsed = normalizeStructuredPlanCandidate( - JSON.parse(jsonPayload) as unknown, - targetKind, - ); - const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); - if (!validation.valid) { - return null; - } - return { - plan: parsed as DocumentMutationPlan, - planState: "validated", - issues: validation.issues, - }; - } catch { - // Fall through to tolerant parsing. - } - } - - const partialPlan = parsePartialStructuredPlan(value, targetKind); - if (!partialPlan) { - return null; - } - const validation = validateDocumentMutationPlanShape(partialPlan, { targetKind }); - if (!validation.valid) { - return null; - } - return { - plan: partialPlan, - planState: "drafted", - issues: validation.issues, - }; -} - -export function resolveExecutionMode( - mutationMode: AIMutationMode, -): AIExecutionMode { - if (mutationMode === "staged-review") { - return "staged-review"; - } - if (mutationMode === "persistent-suggestions") { - return "persistent-suggestions"; - } - return "direct-stream"; -} - -function resolveAllowedPlanKinds(targetKind: AITargetKind): string[] { - if (targetKind === "database") { - return ["database_edit", "review_bundle"]; - } - if (targetKind === "text") { - return ["text_edit", "review_bundle"]; - } - return [ - "text_edit", - "block_insert", - "block_update", - "block_move", - "block_convert", - "review_bundle", - ]; -} - -function buildTargetSummary(workingSet: AIWorkingSetEnvelope | null): string { - if (!workingSet) { - return "No working set available."; - } - - try { - return JSON.stringify(workingSet.context ?? null); - } catch { - return "Working set context could not be serialized."; - } -} - -function readStructuredTargetKind( - workingSet: AIWorkingSetEnvelope | null, -): AITargetKind | null { - if (!workingSet?.context || typeof workingSet.context !== "object") { - return null; - } - - const context = workingSet.context as { - structuredTarget?: { - target?: { - kind?: unknown; - }; - } | null; - }; - - const kind = context.structuredTarget?.target?.kind; - return kind === "block" || kind === "table" || kind === "database" - ? kind - : null; -} - -function normalizeStructuredPlanCandidate( - value: unknown, - targetKind: AITargetKind, -): unknown { - const record = asRecord(value); - if (!record || typeof record.kind !== "string") { - return value; - } - - switch (record.kind) { - case "review_bundle": - return normalizeReviewBundlePlan(record, targetKind); - case "block_insert": - return normalizeBlockInsertPlan(record, targetKind); - case "database_edit": - return normalizeDatabaseEditPlan(record); - default: - return value; - } -} - -function normalizeReviewBundlePlan( - record: Record, - targetKind: AITargetKind, -): Record { - const plans = Array.isArray(record.plans) - ? record.plans.map((plan) => normalizeStructuredPlanCandidate(plan, targetKind)) - : record.plans; - return { - ...record, - label: readNonEmptyString(record.label) ?? "Structured changes", - reason: - readNonEmptyString(record.reason) ?? - "Apply the requested structured changes.", - confidence: normalizeConfidence(record.confidence), - plans, - }; -} - -function normalizeBlockInsertPlan( - record: Record, - targetKind: AITargetKind, -): Record { - const block = asRecord(record.block); - const blockType = - readNonEmptyString(record.blockType) ?? - readNonEmptyString(block?.type) ?? - readNonEmptyString(block?.kind) ?? - (targetKind === "database" ? targetKind : null); - - return { - ...record, - ...(blockType ? { blockType } : {}), - confidence: normalizeConfidence(record.confidence), - position: normalizePosition(record.position), - }; -} - -function normalizeDatabaseEditPlan( - record: Record, -): Record { - const target = asRecord(record.target); - const blockId = - readNonEmptyString(record.blockId) ?? readNonEmptyString(target?.blockId); - return { - ...record, - ...(blockId ? { blockId } : {}), - confidence: normalizeConfidence(record.confidence), - }; -} - -function normalizePosition(value: unknown): unknown { - const position = asRecord(value); - if (!position) { - return value; - } - const parentId = readNonEmptyString(position.parentId); - if (parentId && isFiniteNumber(position.index)) { - if (parentId === "root") { - return position.index <= 0 ? "first" : "last"; - } - return { - parent: parentId, - index: position.index, - }; - } - if ( - parentId === "root" && - ("after" in position || "before" in position) && - !isFiniteNumber(position.index) - ) { - return "last"; - } - const relativeTo = readNonEmptyString(position.relativeTo); - const placement = readNonEmptyString(position.placement); - if (relativeTo === "active") { - if (placement === "before") { - return "first"; - } - if (placement === "after") { - return "last"; - } - } - return value; -} - -function normalizeConfidence(value: unknown): unknown { - if (isFiniteNumber(value)) { - return { score: value }; - } - return value; -} - -function extractJsonObject(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); - const candidate = fencedMatch?.[1]?.trim() ?? trimmed; - return extractBalancedJsonObject(candidate); -} - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readNonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function parsePartialStructuredPlan( - value: string, - targetKind: AITargetKind, -): DocumentMutationPlan | null { - const kind = readStringField(value, "kind"); - if (!kind) { - return null; - } - - if (kind === "review_bundle") { - const nestedPlans = readPartialObjectArray(value, "plans").filter((plan) => - validateDocumentMutationPlanShape(plan, { targetKind }).valid, - ) as DocumentMutationPlan[]; - if (nestedPlans.length === 0) { - return null; - } - return { - kind, - label: readStringField(value, "label") ?? "Streaming review bundle", - reason: - readStringField(value, "reason") ?? - "Previewing mixed structural changes while the plan streams.", - plans: nestedPlans, - }; - } - - if (kind === "text_edit" && targetKind === "text") { - const blockId = readStringField(value, "blockId"); - const operation = readStringField(value, "operation"); - const text = readStringField(value, "text"); - if (!blockId || !operation || text == null) { - return null; - } - return { - kind, - target: { blockId }, - operation: operation as "replace" | "insert" | "append", - text, - }; - } - - if (targetKind === "block") { - if (kind === "block_insert") { - const blockId = readStringField(value, "blockId"); - const blockType = readStringField(value, "blockType"); - const position = readPositionField(value, "position"); - if (!blockType || !position) { - return null; - } - return { - kind, - blockId: blockId ?? undefined, - blockType, - position, - props: - (readObjectField(value, "props") as Record | null) ?? - undefined, - initialText: readStringField(value, "initialText") ?? undefined, - }; - } - - if (kind === "block_update") { - const blockId = readStringField(value, "blockId"); - const props = readObjectField(value, "props"); - if (!blockId || !isRecordValue(props)) { - return null; - } - return { - kind, - blockId, - props, - }; - } - - if (kind === "block_move") { - const blockId = readStringField(value, "blockId"); - const position = readPositionField(value, "position"); - if (!blockId || !position) { - return null; - } - return { - kind, - blockId, - position, - }; - } - - if (kind === "block_convert") { - const blockId = readStringField(value, "blockId"); - const newType = readStringField(value, "newType"); - if (!blockId || !newType) { - return null; - } - return { - kind, - blockId, - newType, - props: - (readObjectField(value, "props") as Record | null) ?? - undefined, - }; - } - } - - if (kind === "database_edit" && targetKind === "database") { - const blockId = readStringField(value, "blockId"); - if (!blockId) { - return null; - } - const steps = readPartialObjectArray(value, "steps"); - if (steps.length === 0) { - return null; - } - return { - kind, - blockId, - steps, - } as DocumentMutationPlan; - } - - return null; -} - -function readStringField(value: string, fieldName: string): string | null { - const fieldMatch = value.match( - new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"`, "m"), - ); - if (!fieldMatch?.[1]) { - return null; - } - try { - return JSON.parse(`"${fieldMatch[1]}"`) as string; - } catch { - return fieldMatch[1]; - } -} - -function readPartialObjectArray(value: string, fieldName: string): unknown[] { - const fieldMatch = value.match( - new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\[`, "m"), - ); - if (!fieldMatch || fieldMatch.index == null) { - return []; - } - - const arrayStart = fieldMatch.index + fieldMatch[0].length - 1; - return readBalancedObjectsFromArray(value.slice(arrayStart)); -} - -function readObjectField(value: string, fieldName: string): unknown | null { - const fieldMatch = value.match( - new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\{`, "m"), - ); - if (!fieldMatch || fieldMatch.index == null) { - return null; - } - - const objectStart = fieldMatch.index + fieldMatch[0].length - 1; - const objectText = extractBalancedJsonObject(value.slice(objectStart)); - if (!objectText) { - return null; - } - try { - return JSON.parse(objectText) as unknown; - } catch { - return null; - } -} - -function readPositionField( - value: string, - fieldName: string, -): "first" | "last" | { before: string } | { after: string } | { parent: string; index: number } | null { - const stringValue = readStringField(value, fieldName); - if (stringValue === "first" || stringValue === "last") { - return stringValue; - } - - const objectValue = readObjectField(value, fieldName); - if (!isRecordValue(objectValue)) { - return null; - } - if (typeof objectValue.before === "string" && objectValue.before.length > 0) { - return { before: objectValue.before }; - } - if (typeof objectValue.after === "string" && objectValue.after.length > 0) { - return { after: objectValue.after }; - } - if ( - typeof objectValue.parent === "string" && - objectValue.parent.length > 0 && - typeof objectValue.index === "number" - ) { - return { parent: objectValue.parent, index: objectValue.index }; - } - return null; -} - -function readBalancedObjectsFromArray(value: string): unknown[] { - const parsedValues: unknown[] = []; - let depth = 0; - let inString = false; - let isEscaped = false; - let objectStart = -1; - - for (let index = 0; index < value.length; index += 1) { - const character = value[index]!; - if (isEscaped) { - isEscaped = false; - continue; - } - if (character === "\\") { - isEscaped = true; - continue; - } - if (character === "\"") { - inString = !inString; - continue; - } - if (inString) { - continue; - } - if (character === "{") { - if (depth === 0) { - objectStart = index; - } - depth += 1; - continue; - } - if (character === "}") { - depth -= 1; - if (depth === 0 && objectStart >= 0) { - const objectText = value.slice(objectStart, index + 1); - try { - parsedValues.push(JSON.parse(objectText) as unknown); - } catch { - return parsedValues; - } - objectStart = -1; - } - } - } - - return parsedValues; -} - -function extractBalancedJsonObject(value: string): string | null { - const startIndex = value.indexOf("{"); - if (startIndex === -1) { - return null; - } - - let depth = 0; - let inString = false; - let isEscaped = false; - for (let index = startIndex; index < value.length; index += 1) { - const character = value[index]!; - if (isEscaped) { - isEscaped = false; - continue; - } - if (character === "\\") { - isEscaped = true; - continue; - } - if (character === "\"") { - inString = !inString; - continue; - } - if (inString) { - continue; - } - if (character === "{") { - depth += 1; - continue; - } - if (character === "}") { - depth -= 1; - if (depth === 0) { - return value.slice(startIndex, index + 1); - } - } - } - - return null; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function isRecordValue(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export { resolvePlannerMode, resolveGenerationTargetKind, buildPlannerPrompt, parseStructuredPlanResult, parseStructuredPlanPreview, resolveExecutionMode } from "./structuredPlannerParts/structuredPlannerPart1"; +export type { StructuredPlannerConfig, StructuredPlannerParseResult } from "./structuredPlannerParts/structuredPlannerPart1"; diff --git a/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts new file mode 100644 index 0000000..f6076e8 --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart1.ts @@ -0,0 +1,374 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { + AIExecutionMode, + AIPlannerMode, + AITargetKind, + AIMutationMode, +} from "../contracts"; +import type { DocumentMutationPlan } from "../planTypes"; +import { + validateDocumentMutationPlanShape, + type PlanValidationIssue, +} from "../planValidation"; +import { normalizeConfidence, extractJsonObject, asRecord, readNonEmptyString, isFiniteNumber, parsePartialStructuredPlan, readStringField, readPartialObjectArray, readObjectField, readPositionField, readBalancedObjectsFromArray, extractBalancedJsonObject, escapeRegExp, isRecordValue } from "./structuredPlannerPart2"; + +export interface StructuredPlannerConfig { + prompt: string; + targetKind: AITargetKind; + workingSet: AIWorkingSetEnvelope | null; +} + +export interface StructuredPlannerParseResult { + plan: DocumentMutationPlan | null; + planState: "drafted" | "validated" | "rejected"; + issues: PlanValidationIssue[]; +} + +export function resolvePlannerMode(options: { + targetKind: AITargetKind; + intent: "rewrite" | "continue" | "local-edit" | "structural" | "search" | "review" | "unknown"; + target: "selection" | "block"; +}): AIPlannerMode { + if (options.target === "selection") { + return "text"; + } + if (options.targetKind === "database") { + return "structured"; + } + if (options.targetKind === "table") { + return "text"; + } + if (options.intent === "structural" || options.intent === "review") { + return "structured"; + } + return "text"; +} + +export function resolveGenerationTargetKind(options: { + target: "selection" | "block"; + blockType: string | null; + workingSet: AIWorkingSetEnvelope | null; +}): AITargetKind { + if (options.target === "selection") { + return "text"; + } + + const structuredKind = readStructuredTargetKind(options.workingSet); + if (structuredKind) { + return structuredKind; + } + + if (options.blockType === "table") { + return "table"; + } + if (options.blockType === "database") { + return "database"; + } + return "block"; +} + +export function buildPlannerPrompt( + config: StructuredPlannerConfig, +): string { + const allowedPlanKinds = resolveAllowedPlanKinds(config.targetKind); + const targetSummary = buildTargetSummary(config.workingSet); + + return [ + "Produce a structured Pen document mutation plan.", + "Return exactly one JSON object and no markdown fences or prose.", + `Target kind: ${config.targetKind}`, + `Allowed top-level plan kinds: ${allowedPlanKinds.join(", ")}`, + "", + "Use these JSON-shape rules:", + '- include a top-level "kind" string', + "- include all required object properties for the chosen plan kind", + "- use arrays only for ordered step lists", + "- use nested objects for target, position, confidence, and patch payloads", + "- for review bundles, return a review_bundle with nested plans", + "- if later plans need to target a newly inserted block, include blockId on the block_insert plan and reuse that same blockId in later plans", + "- for new databases, prefer a review_bundle that inserts the block first and then applies database_edit steps to that inserted block", + "", + "Context summary:", + targetSummary, + "", + "User request:", + config.prompt, + ].join("\n"); +} + +export function parseStructuredPlanResult( + value: string, + targetKind: AITargetKind, +): StructuredPlannerParseResult { + if (targetKind === "table") { + return { + plan: null, + planState: "rejected", + issues: [ + { + path: "plan", + code: "invalid-kind", + severity: "error", + message: + "Structured table plans are not supported. Use the markdown authoring lane for tables.", + }, + ], + }; + } + const jsonPayload = extractJsonObject(value); + if (!jsonPayload) { + return { + plan: null, + planState: "rejected", + issues: [ + { + path: "plan", + code: "invalid-shape", + severity: "error", + message: "Planner response did not contain a JSON object.", + }, + ], + }; + } + + try { + const parsed = normalizeStructuredPlanCandidate( + JSON.parse(jsonPayload) as unknown, + targetKind, + ); + const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); + return { + plan: validation.valid ? (parsed as DocumentMutationPlan) : null, + planState: validation.valid ? "validated" : "rejected", + issues: validation.issues, + }; + } catch (error) { + return { + plan: null, + planState: "rejected", + issues: [ + { + path: "plan", + code: "invalid-shape", + severity: "error", + message: + error instanceof Error + ? error.message + : "Planner response could not be parsed as JSON.", + }, + ], + }; + } +} + +export function parseStructuredPlanPreview( + value: string, + targetKind: AITargetKind, +): StructuredPlannerParseResult | null { + if (targetKind === "table") { + return null; + } + const jsonPayload = extractJsonObject(value); + if (jsonPayload) { + try { + const parsed = normalizeStructuredPlanCandidate( + JSON.parse(jsonPayload) as unknown, + targetKind, + ); + const validation = validateDocumentMutationPlanShape(parsed, { targetKind }); + if (!validation.valid) { + return null; + } + return { + plan: parsed as DocumentMutationPlan, + planState: "validated", + issues: validation.issues, + }; + } catch { + // Fall through to tolerant parsing. + } + } + + const partialPlan = parsePartialStructuredPlan(value, targetKind); + if (!partialPlan) { + return null; + } + const validation = validateDocumentMutationPlanShape(partialPlan, { targetKind }); + if (!validation.valid) { + return null; + } + return { + plan: partialPlan, + planState: "drafted", + issues: validation.issues, + }; +} + +export function resolveExecutionMode( + mutationMode: AIMutationMode, +): AIExecutionMode { + if (mutationMode === "staged-review") { + return "staged-review"; + } + if (mutationMode === "persistent-suggestions") { + return "persistent-suggestions"; + } + return "direct-stream"; +} + +export function resolveAllowedPlanKinds(targetKind: AITargetKind): string[] { + if (targetKind === "database") { + return ["database_edit", "review_bundle"]; + } + if (targetKind === "text") { + return ["text_edit", "review_bundle"]; + } + return [ + "text_edit", + "block_insert", + "block_update", + "block_move", + "block_convert", + "review_bundle", + ]; +} + +export function buildTargetSummary(workingSet: AIWorkingSetEnvelope | null): string { + if (!workingSet) { + return "No working set available."; + } + + try { + return JSON.stringify(workingSet.context ?? null); + } catch { + return "Working set context could not be serialized."; + } +} + +export function readStructuredTargetKind( + workingSet: AIWorkingSetEnvelope | null, +): AITargetKind | null { + if (!workingSet?.context || typeof workingSet.context !== "object") { + return null; + } + + const context = workingSet.context as { + structuredTarget?: { + target?: { + kind?: unknown; + }; + } | null; + }; + + const kind = context.structuredTarget?.target?.kind; + return kind === "block" || kind === "table" || kind === "database" + ? kind + : null; +} + +export function normalizeStructuredPlanCandidate( + value: unknown, + targetKind: AITargetKind, +): unknown { + const record = asRecord(value); + if (!record || typeof record.kind !== "string") { + return value; + } + + switch (record.kind) { + case "review_bundle": + return normalizeReviewBundlePlan(record, targetKind); + case "block_insert": + return normalizeBlockInsertPlan(record, targetKind); + case "database_edit": + return normalizeDatabaseEditPlan(record); + default: + return value; + } +} + +export function normalizeReviewBundlePlan( + record: Record, + targetKind: AITargetKind, +): Record { + const plans = Array.isArray(record.plans) + ? record.plans.map((plan) => normalizeStructuredPlanCandidate(plan, targetKind)) + : record.plans; + return { + ...record, + label: readNonEmptyString(record.label) ?? "Structured changes", + reason: + readNonEmptyString(record.reason) ?? + "Apply the requested structured changes.", + confidence: normalizeConfidence(record.confidence), + plans, + }; +} + +export function normalizeBlockInsertPlan( + record: Record, + targetKind: AITargetKind, +): Record { + const block = asRecord(record.block); + const blockType = + readNonEmptyString(record.blockType) ?? + readNonEmptyString(block?.type) ?? + readNonEmptyString(block?.kind) ?? + (targetKind === "database" ? targetKind : null); + + return { + ...record, + ...(blockType ? { blockType } : {}), + confidence: normalizeConfidence(record.confidence), + position: normalizePosition(record.position), + }; +} + +export function normalizeDatabaseEditPlan( + record: Record, +): Record { + const target = asRecord(record.target); + const blockId = + readNonEmptyString(record.blockId) ?? readNonEmptyString(target?.blockId); + return { + ...record, + ...(blockId ? { blockId } : {}), + confidence: normalizeConfidence(record.confidence), + }; +} + +export function normalizePosition(value: unknown): unknown { + const position = asRecord(value); + if (!position) { + return value; + } + const parentId = readNonEmptyString(position.parentId); + if (parentId && isFiniteNumber(position.index)) { + if (parentId === "root") { + return position.index <= 0 ? "first" : "last"; + } + return { + parent: parentId, + index: position.index, + }; + } + if ( + parentId === "root" && + ("after" in position || "before" in position) && + !isFiniteNumber(position.index) + ) { + return "last"; + } + const relativeTo = readNonEmptyString(position.relativeTo); + const placement = readNonEmptyString(position.placement); + if (relativeTo === "active") { + if (placement === "before") { + return "first"; + } + if (placement === "after") { + return "last"; + } + } + return value; +} diff --git a/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts new file mode 100644 index 0000000..07d408c --- /dev/null +++ b/packages/extensions/ai/src/runtime/structuredPlannerParts/structuredPlannerPart2.ts @@ -0,0 +1,342 @@ +// @ts-nocheck +import type { AIWorkingSetEnvelope } from "../../types"; +import type { + AIExecutionMode, + AIPlannerMode, + AITargetKind, + AIMutationMode, +} from "../contracts"; +import type { DocumentMutationPlan } from "../planTypes"; +import { + validateDocumentMutationPlanShape, + type PlanValidationIssue, +} from "../planValidation"; +import { resolvePlannerMode, resolveGenerationTargetKind, buildPlannerPrompt, parseStructuredPlanResult, parseStructuredPlanPreview, resolveExecutionMode, resolveAllowedPlanKinds, buildTargetSummary, readStructuredTargetKind, normalizeStructuredPlanCandidate, normalizeReviewBundlePlan, normalizeBlockInsertPlan, normalizeDatabaseEditPlan, normalizePosition } from "./structuredPlannerPart1"; +import type { StructuredPlannerConfig, StructuredPlannerParseResult } from "./structuredPlannerPart1"; + +export function normalizeConfidence(value: unknown): unknown { + if (isFiniteNumber(value)) { + return { score: value }; + } + return value; +} + +export function extractJsonObject(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fencedMatch?.[1]?.trim() ?? trimmed; + return extractBalancedJsonObject(candidate); +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function parsePartialStructuredPlan( + value: string, + targetKind: AITargetKind, +): DocumentMutationPlan | null { + const kind = readStringField(value, "kind"); + if (!kind) { + return null; + } + + if (kind === "review_bundle") { + const nestedPlans = readPartialObjectArray(value, "plans").filter((plan) => + validateDocumentMutationPlanShape(plan, { targetKind }).valid, + ) as DocumentMutationPlan[]; + if (nestedPlans.length === 0) { + return null; + } + return { + kind, + label: readStringField(value, "label") ?? "Streaming review bundle", + reason: + readStringField(value, "reason") ?? + "Previewing mixed structural changes while the plan streams.", + plans: nestedPlans, + }; + } + + if (kind === "text_edit" && targetKind === "text") { + const blockId = readStringField(value, "blockId"); + const operation = readStringField(value, "operation"); + const text = readStringField(value, "text"); + if (!blockId || !operation || text == null) { + return null; + } + return { + kind, + target: { blockId }, + operation: operation as "replace" | "insert" | "append", + text, + }; + } + + if (targetKind === "block") { + if (kind === "block_insert") { + const blockId = readStringField(value, "blockId"); + const blockType = readStringField(value, "blockType"); + const position = readPositionField(value, "position"); + if (!blockType || !position) { + return null; + } + return { + kind, + blockId: blockId ?? undefined, + blockType, + position, + props: + (readObjectField(value, "props") as Record | null) ?? + undefined, + initialText: readStringField(value, "initialText") ?? undefined, + }; + } + + if (kind === "block_update") { + const blockId = readStringField(value, "blockId"); + const props = readObjectField(value, "props"); + if (!blockId || !isRecordValue(props)) { + return null; + } + return { + kind, + blockId, + props, + }; + } + + if (kind === "block_move") { + const blockId = readStringField(value, "blockId"); + const position = readPositionField(value, "position"); + if (!blockId || !position) { + return null; + } + return { + kind, + blockId, + position, + }; + } + + if (kind === "block_convert") { + const blockId = readStringField(value, "blockId"); + const newType = readStringField(value, "newType"); + if (!blockId || !newType) { + return null; + } + return { + kind, + blockId, + newType, + props: + (readObjectField(value, "props") as Record | null) ?? + undefined, + }; + } + } + + if (kind === "database_edit" && targetKind === "database") { + const blockId = readStringField(value, "blockId"); + if (!blockId) { + return null; + } + const steps = readPartialObjectArray(value, "steps"); + if (steps.length === 0) { + return null; + } + return { + kind, + blockId, + steps, + } as DocumentMutationPlan; + } + + return null; +} + +export function readStringField(value: string, fieldName: string): string | null { + const fieldMatch = value.match( + new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*"([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"`, "m"), + ); + if (!fieldMatch?.[1]) { + return null; + } + try { + return JSON.parse(`"${fieldMatch[1]}"`) as string; + } catch { + return fieldMatch[1]; + } +} + +export function readPartialObjectArray(value: string, fieldName: string): unknown[] { + const fieldMatch = value.match( + new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\[`, "m"), + ); + if (!fieldMatch || fieldMatch.index == null) { + return []; + } + + const arrayStart = fieldMatch.index + fieldMatch[0].length - 1; + return readBalancedObjectsFromArray(value.slice(arrayStart)); +} + +export function readObjectField(value: string, fieldName: string): unknown | null { + const fieldMatch = value.match( + new RegExp(`"${escapeRegExp(fieldName)}"\\s*:\\s*\\{`, "m"), + ); + if (!fieldMatch || fieldMatch.index == null) { + return null; + } + + const objectStart = fieldMatch.index + fieldMatch[0].length - 1; + const objectText = extractBalancedJsonObject(value.slice(objectStart)); + if (!objectText) { + return null; + } + try { + return JSON.parse(objectText) as unknown; + } catch { + return null; + } +} + +export function readPositionField( + value: string, + fieldName: string, +): "first" | "last" | { before: string } | { after: string } | { parent: string; index: number } | null { + const stringValue = readStringField(value, fieldName); + if (stringValue === "first" || stringValue === "last") { + return stringValue; + } + + const objectValue = readObjectField(value, fieldName); + if (!isRecordValue(objectValue)) { + return null; + } + if (typeof objectValue.before === "string" && objectValue.before.length > 0) { + return { before: objectValue.before }; + } + if (typeof objectValue.after === "string" && objectValue.after.length > 0) { + return { after: objectValue.after }; + } + if ( + typeof objectValue.parent === "string" && + objectValue.parent.length > 0 && + typeof objectValue.index === "number" + ) { + return { parent: objectValue.parent, index: objectValue.index }; + } + return null; +} + +export function readBalancedObjectsFromArray(value: string): unknown[] { + const parsedValues: unknown[] = []; + let depth = 0; + let inString = false; + let isEscaped = false; + let objectStart = -1; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]!; + if (isEscaped) { + isEscaped = false; + continue; + } + if (character === "\\") { + isEscaped = true; + continue; + } + if (character === "\"") { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (character === "{") { + if (depth === 0) { + objectStart = index; + } + depth += 1; + continue; + } + if (character === "}") { + depth -= 1; + if (depth === 0 && objectStart >= 0) { + const objectText = value.slice(objectStart, index + 1); + try { + parsedValues.push(JSON.parse(objectText) as unknown); + } catch { + return parsedValues; + } + objectStart = -1; + } + } + } + + return parsedValues; +} + +export function extractBalancedJsonObject(value: string): string | null { + const startIndex = value.indexOf("{"); + if (startIndex === -1) { + return null; + } + + let depth = 0; + let inString = false; + let isEscaped = false; + for (let index = startIndex; index < value.length; index += 1) { + const character = value[index]!; + if (isEscaped) { + isEscaped = false; + continue; + } + if (character === "\\") { + isEscaped = true; + continue; + } + if (character === "\"") { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (character === "{") { + depth += 1; + continue; + } + if (character === "}") { + depth -= 1; + if (depth === 0) { + return value.slice(startIndex, index + 1); + } + } + } + + return null; +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function isRecordValue(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/extensions/ai/src/runtime/suggestedOperationRunner.ts b/packages/extensions/ai/src/runtime/suggestedOperationRunner.ts new file mode 100644 index 0000000..d83689f --- /dev/null +++ b/packages/extensions/ai/src/runtime/suggestedOperationRunner.ts @@ -0,0 +1,57 @@ +import type { Editor, OpOrigin } from "@pen/types"; +import type { DocumentOp } from "@pen/types"; +import type { GenerationState, AISession } from "../types"; +import { applySuggestedAIOperations } from "../suggestions/applySuggestedAIOperations"; +import { AI_SESSION_SUGGESTION_ORIGIN } from "../suggestions/suggestMode"; + +export interface SuggestedAIOperationRunnerOptions { + editor: Editor; + author: string; + model?: string; + getSession: (sessionId: string) => AISession | null; + getActiveGeneration: () => GenerationState | null; +} + +export class SuggestedAIOperationRunner { + constructor(private readonly options: SuggestedAIOperationRunnerOptions) {} + + apply( + operations: DocumentOp[], + sessionId?: string, + options?: { + createdAt?: number; + generationId?: string; + origin?: OpOrigin; + requestId?: string; + suggestionIds?: readonly string[]; + turnId?: string; + undoGroupId?: string; + }, + ): void { + const session = + sessionId != null ? this.options.getSession(sessionId) : null; + const activeGeneration = this.options.getActiveGeneration(); + const undoGroupId = + options?.undoGroupId ?? + (session?.surface === "bottom-chat" && + activeGeneration != null && + activeGeneration.sessionId === sessionId + ? activeGeneration.undoGroupId + : undefined); + + applySuggestedAIOperations(this.options.editor, { + operations, + author: this.options.author, + authorType: "ai", + model: this.options.model, + createdAt: options?.createdAt, + generationId: options?.generationId, + requestId: options?.requestId, + sessionId, + suggestionIds: options?.suggestionIds, + turnId: options?.turnId, + origin: options?.origin ?? (sessionId ? AI_SESSION_SUGGESTION_ORIGIN : "extension"), + undoGroupId, + }); + } +} diff --git a/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts b/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts new file mode 100644 index 0000000..0c74afc --- /dev/null +++ b/packages/extensions/ai/src/suggestions/applySuggestedAIOperations.ts @@ -0,0 +1,63 @@ +import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; +import type { PersistentSuggestion } from "../types"; +import { + AI_SESSION_SUGGESTION_ORIGIN, + interceptApplyForSuggestModeWithMetadata, +} from "./suggestMode"; + +export type ApplySuggestedAIOperationsOptions = { + operations: readonly DocumentOp[]; + author?: string; + authorType?: "user" | "ai"; + model?: string; + requestId?: string; + sessionId?: string; + turnId?: string; + generationId?: string; + suggestionIds?: readonly string[]; + createdAt?: number; + origin?: OpOrigin; + undoGroupId?: string; +}; + +export type ApplySuggestedAIOperationsResult = { + suggestionIds: string[]; + suggestions: PersistentSuggestion[]; +}; + +export function applySuggestedAIOperations( + editor: Editor, + options: ApplySuggestedAIOperationsOptions, +): ApplySuggestedAIOperationsResult { + if (options.operations.length === 0) { + return { suggestionIds: [], suggestions: [] }; + } + + const intercepted = interceptApplyForSuggestModeWithMetadata( + [...options.operations], + editor, + options.author ?? "assistant", + options.authorType ?? "ai", + options.model, + options.sessionId, + { + requestId: options.requestId, + turnId: options.turnId, + generationId: options.generationId, + createdAt: options.createdAt, + suggestionIds: options.suggestionIds, + }, + ); + + editor.apply(intercepted.operations, { + origin: options.origin ?? AI_SESSION_SUGGESTION_ORIGIN, + ...(options.undoGroupId + ? { undoGroupId: options.undoGroupId } + : { undoGroup: true }), + }); + + return { + suggestionIds: intercepted.suggestionIds, + suggestions: intercepted.suggestions, + }; +} diff --git a/packages/extensions/ai/src/suggestions/persistent.ts b/packages/extensions/ai/src/suggestions/persistent.ts index 2486da5..fac7507 100644 --- a/packages/extensions/ai/src/suggestions/persistent.ts +++ b/packages/extensions/ai/src/suggestions/persistent.ts @@ -1,12 +1,17 @@ -import type { - BlockHandle, - Editor, - Position, -} from "@pen/types"; -import type { - BlockSuggestionMeta, - PersistentSuggestion, -} from "../types"; +import type { BlockHandle, Editor, Position } from "@pen/types"; +import type { BlockSuggestionMeta, PersistentSuggestion } from "../types"; + +export type BlockSuggestionMetaPayload = BlockSuggestionMeta & + Record; + +export type SuggestionCreationOptions = { + suggestionId?: string; + requestId?: string; + sessionId?: string; + turnId?: string; + generationId?: string; + createdAt?: number; +}; type DeltaFragment = { insert: string | object; @@ -28,7 +33,8 @@ export function readSuggestionsFromBlock( let offset = 0; for (const delta of ytext.toDelta()) { - const length = typeof delta.insert === "string" ? delta.insert.length : 1; + const length = + typeof delta.insert === "string" ? delta.insert.length : 1; const suggestion = asSuggestion(delta.attributes?.suggestion); if (suggestion) { suggestions.push({ @@ -40,6 +46,9 @@ export function readSuggestionsFromBlock( createdAt: suggestion.createdAt, model: suggestion.model, sessionId: suggestion.sessionId, + requestId: suggestion.requestId, + turnId: suggestion.turnId, + generationId: suggestion.generationId, blockId, offset, length, @@ -65,6 +74,9 @@ export function readAllSuggestions(editor: Editor): PersistentSuggestion[] { createdAt: blockSuggestion.createdAt, model: blockSuggestion.model, sessionId: blockSuggestion.sessionId, + requestId: blockSuggestion.requestId, + turnId: blockSuggestion.turnId, + generationId: blockSuggestion.generationId, blockId: block.id, previousState: blockSuggestion.previousState, }); @@ -79,18 +91,43 @@ export function readBlockSuggestionMeta( ): BlockSuggestionMeta | null { if (!block) return null; const meta = block.meta("suggestion"); - if (!meta) return null; + return parseBlockSuggestionMeta(meta); +} + +export function serializeBlockSuggestionMeta( + meta: BlockSuggestionMeta, +): BlockSuggestionMetaPayload { + return { + id: meta.id, + action: meta.action, + author: meta.author, + authorType: meta.authorType, + createdAt: meta.createdAt, + model: meta.model, + sessionId: meta.sessionId, + requestId: meta.requestId, + turnId: meta.turnId, + generationId: meta.generationId, + previousState: meta.previousState, + }; +} + +export function parseBlockSuggestionMeta( + meta: unknown, +): BlockSuggestionMeta | null { + if (!meta || typeof meta !== "object") return null; + const record = meta as Record; if ( - typeof meta.id !== "string" || - typeof meta.action !== "string" || - typeof meta.author !== "string" || - typeof meta.authorType !== "string" || - typeof meta.createdAt !== "number" + typeof record.id !== "string" || + typeof record.action !== "string" || + typeof record.author !== "string" || + typeof record.authorType !== "string" || + typeof record.createdAt !== "number" ) { return null; } - const action = meta.action; + const action = record.action; if ( action !== "insert-block" && action !== "delete-block" && @@ -101,14 +138,22 @@ export function readBlockSuggestionMeta( } return { - id: meta.id, + id: record.id, action, - author: meta.author, - authorType: meta.authorType === "ai" ? "ai" : "user", - createdAt: meta.createdAt, - model: typeof meta.model === "string" ? meta.model : undefined, - sessionId: typeof meta.sessionId === "string" ? meta.sessionId : undefined, - previousState: readPreviousState(meta.previousState), + author: record.author, + authorType: record.authorType === "ai" ? "ai" : "user", + createdAt: record.createdAt, + model: typeof record.model === "string" ? record.model : undefined, + sessionId: + typeof record.sessionId === "string" ? record.sessionId : undefined, + requestId: + typeof record.requestId === "string" ? record.requestId : undefined, + turnId: typeof record.turnId === "string" ? record.turnId : undefined, + generationId: + typeof record.generationId === "string" + ? record.generationId + : undefined, + previousState: readPreviousState(record.previousState), }; } @@ -118,16 +163,21 @@ export function createSuggestionMark( authorType: "user" | "ai", model?: string, sessionId?: string, + options: SuggestionCreationOptions = {}, ): Record { + const resolvedSessionId = options.sessionId ?? sessionId; return { suggestion: { - id: crypto.randomUUID(), + id: options.suggestionId ?? crypto.randomUUID(), action, author, authorType, - createdAt: Date.now(), + createdAt: options.createdAt ?? Date.now(), model, - sessionId, + sessionId: resolvedSessionId, + requestId: options.requestId, + turnId: options.turnId, + generationId: options.generationId, }, }; } @@ -160,9 +210,7 @@ function isPosition(value: unknown): value is Position { ); } -function asSuggestion( - value: unknown, -): { +function asSuggestion(value: unknown): { id: string; action: "insert" | "delete"; author: string; @@ -170,6 +218,9 @@ function asSuggestion( createdAt: number; model?: string; sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; } | null { if (!value || typeof value !== "object") return null; const record = value as Record; @@ -193,12 +244,21 @@ function asSuggestion( model: typeof record.model === "string" ? record.model : undefined, sessionId: typeof record.sessionId === "string" ? record.sessionId : undefined, + requestId: + typeof record.requestId === "string" ? record.requestId : undefined, + turnId: typeof record.turnId === "string" ? record.turnId : undefined, + generationId: + typeof record.generationId === "string" + ? record.generationId + : undefined, }; } function getYText(editor: Editor, blockId: string): YTextLike | null { try { - return (editor.internals.getBlockText(blockId) as YTextLike | null) ?? null; + return ( + (editor.internals.getBlockText(blockId) as YTextLike | null) ?? null + ); } catch { return null; } diff --git a/packages/extensions/ai/src/suggestions/replacementPlan/blockRangeTextOffset.ts b/packages/extensions/ai/src/suggestions/replacementPlan/blockRangeTextOffset.ts new file mode 100644 index 0000000..5df4e80 --- /dev/null +++ b/packages/extensions/ai/src/suggestions/replacementPlan/blockRangeTextOffset.ts @@ -0,0 +1,88 @@ +import type { Editor } from "@pen/types"; +import type { BlockRangeStreamingPreviewPlan } from "./streamingPreviewPlan"; + +export function mapStreamingBlockRangeTextOffset( + editor: Editor, + normalizedRange: BlockRangeStreamingPreviewPlan["normalizedRange"], + charOffset: number, +): { blockId: string; offset: number } { + const segments: Array<{ + blockId: string; + startOffset: number; + text: string; + }> = []; + + if ( + normalizedRange.start.blockId === normalizedRange.end.blockId && + normalizedRange.middleBlockIds.length === 0 + ) { + const blockText = + editor.getBlock(normalizedRange.start.blockId)?.textContent() ?? ""; + const from = Math.min( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + const to = Math.max( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + return { + blockId: normalizedRange.start.blockId, + offset: Math.min(to, from + Math.max(0, charOffset)), + }; + } + + const startBlockText = + editor.getBlock(normalizedRange.start.blockId)?.textContent() ?? ""; + segments.push({ + blockId: normalizedRange.start.blockId, + startOffset: normalizedRange.start.offset, + text: startBlockText.slice(normalizedRange.start.offset), + }); + + for (const blockId of normalizedRange.middleBlockIds) { + segments.push({ + blockId, + startOffset: 0, + text: editor.getBlock(blockId)?.textContent() ?? "", + }); + } + + const endBlockText = + editor.getBlock(normalizedRange.end.blockId)?.textContent() ?? ""; + segments.push({ + blockId: normalizedRange.end.blockId, + startOffset: 0, + text: endBlockText.slice(0, normalizedRange.end.offset), + }); + + let cursor = 0; + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (!segment) { + continue; + } + + if (charOffset <= cursor + segment.text.length) { + return { + blockId: segment.blockId, + offset: segment.startOffset + Math.max(0, charOffset - cursor), + }; + } + + cursor += segment.text.length; + if (index < segments.length - 1) { + cursor += 1; + } + } + + const lastSegment = segments[segments.length - 1]; + if (!lastSegment) { + return normalizedRange.end; + } + + return { + blockId: lastSegment.blockId, + offset: lastSegment.startOffset + lastSegment.text.length, + }; +} diff --git a/packages/extensions/ai/src/suggestions/replacementPlan/rangeReplacementOps.ts b/packages/extensions/ai/src/suggestions/replacementPlan/rangeReplacementOps.ts new file mode 100644 index 0000000..27f0e82 --- /dev/null +++ b/packages/extensions/ai/src/suggestions/replacementPlan/rangeReplacementOps.ts @@ -0,0 +1,288 @@ +import type { DocumentOp } from "@pen/types"; +import { + resolveSelectedRangeTextFragments, + splitReplacementParagraphs, + type NormalizedReplacementRange, +} from "./replacementRange"; +import { hasLineBreak } from "./sharedTextDiff"; +import { compileReplacementSuggestionOps } from "./textDiffEngine"; + +export type ReplacementReviewOperation = Extract< + DocumentOp, + { + type: + | "delete-block" + | "delete-text" + | "insert-block" + | "insert-text" + | "replace-text"; + } +>; + +export const DEFAULT_INSERTED_BLOCK_TYPE = "paragraph"; + +export function createDefaultReplacementBlockId(): string { + const randomId = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return `ai-paragraph-${randomId}`; +} + +export function buildSingleBlockReplacementOperations({ + blockId, + blockType, + createBlockId, + maxDiffCells, + offset, + originalText, + replacementText, +}: { + blockId: string; + blockType: string; + createBlockId: () => string; + maxDiffCells?: number; + offset: number; + originalText: string; + replacementText: string; +}): ReplacementReviewOperation[] { + const replacementParagraphs = splitReplacementParagraphs(replacementText); + const shouldSplitIntoParagraphBlocks = + replacementParagraphs !== undefined && !hasLineBreak(originalText); + const firstParagraphText = shouldSplitIntoParagraphBlocks + ? (replacementParagraphs?.[0] ?? "") + : replacementText; + const operations: ReplacementReviewOperation[] = [ + ...compileReplacementSuggestionOps({ + blockId, + maxDiffCells, + offset, + originalText, + replacementText: firstParagraphText, + }), + ]; + + if ( + !shouldSplitIntoParagraphBlocks || + !replacementParagraphs || + replacementParagraphs.length <= 1 + ) { + return operations; + } + + return [ + ...operations, + ...buildInsertedParagraphBlockOperations({ + afterBlockId: blockId, + blockType, + createBlockId, + paragraphs: replacementParagraphs.slice(1), + }), + ]; +} + +export function buildMultiBlockReplacementOperations({ + blockType, + createBlockId, + maxDiffCells, + normalizedRange, + replacementText, +}: { + blockType: string; + createBlockId: () => string; + maxDiffCells?: number; + normalizedRange: NormalizedReplacementRange; + replacementText: string; +}): ReplacementReviewOperation[] { + const replacementParagraphs = splitReplacementParagraphs(replacementText); + const firstReplacementText = replacementParagraphs + ? (replacementParagraphs[0] ?? "") + : replacementText; + const alignedParagraphOperations = replacementParagraphs + ? buildAlignedMultiBlockParagraphReplacementOperations({ + maxDiffCells, + normalizedRange, + replacementParagraphs, + }) + : null; + if (alignedParagraphOperations) { + return alignedParagraphOperations; + } + const operations: ReplacementReviewOperation[] = []; + + if (normalizedRange.start.offset < normalizedRange.startBlock.text.length) { + operations.push({ + type: "delete-text", + blockId: normalizedRange.start.blockId, + offset: normalizedRange.start.offset, + length: + normalizedRange.startBlock.text.length - + normalizedRange.start.offset, + }); + } + + if (normalizedRange.end.offset > 0) { + operations.push({ + type: "delete-text", + blockId: normalizedRange.end.blockId, + offset: 0, + length: normalizedRange.end.offset, + }); + } + + for (const block of normalizedRange.middleBlocks) { + operations.push({ type: "delete-block", blockId: block.id }); + } + + if (firstReplacementText.length > 0) { + operations.push( + ...compileReplacementSuggestionOps({ + blockId: normalizedRange.start.blockId, + maxDiffCells, + offset: normalizedRange.start.offset, + originalText: "", + replacementText: firstReplacementText, + }), + ); + } + + const insertedParagraphBlocks = replacementParagraphs + ? buildInsertedParagraphBlocks({ + afterBlockId: normalizedRange.start.blockId, + blockType, + createBlockId, + paragraphs: replacementParagraphs.slice(1), + }) + : []; + operations.push( + ...insertedParagraphBlocks.flatMap(toInsertedParagraphBlockOperations), + ); + + const endSuffix = normalizedRange.endBlock.text.slice( + normalizedRange.end.offset, + ); + if (endSuffix.length > 0) { + const suffixBlock = insertedParagraphBlocks.at(-1); + operations.push({ + type: "insert-text", + blockId: suffixBlock?.blockId ?? normalizedRange.start.blockId, + offset: suffixBlock + ? suffixBlock.text.length + : normalizedRange.start.offset + firstReplacementText.length, + text: endSuffix, + }); + } + + operations.push({ + type: "delete-block", + blockId: normalizedRange.end.blockId, + }); + + return operations; +} + +export function buildAlignedMultiBlockParagraphReplacementOperations({ + maxDiffCells, + normalizedRange, + replacementParagraphs, +}: { + maxDiffCells?: number; + normalizedRange: NormalizedReplacementRange; + replacementParagraphs: readonly string[]; +}): ReplacementReviewOperation[] | null { + const fragments = resolveSelectedRangeTextFragments(normalizedRange); + if ( + fragments.length !== replacementParagraphs.length || + fragments.some((fragment) => fragment.text.length === 0) + ) { + return null; + } + + return fragments.flatMap((fragment, index) => + compileReplacementSuggestionOps({ + blockId: fragment.blockId, + maxDiffCells, + offset: fragment.offset, + originalText: fragment.text, + replacementText: replacementParagraphs[index] ?? "", + }), + ); +} + +export function buildInsertedParagraphBlockOperations({ + afterBlockId, + blockType, + createBlockId, + paragraphs, +}: { + afterBlockId: string; + blockType: string; + createBlockId: () => string; + paragraphs: readonly string[]; +}): ReplacementReviewOperation[] { + return buildInsertedParagraphBlocks({ + afterBlockId, + blockType, + createBlockId, + paragraphs, + }).flatMap(toInsertedParagraphBlockOperations); +} + +export interface InsertedParagraphBlock { + afterBlockId: string; + blockId: string; + blockType: string; + text: string; +} + +export function buildInsertedParagraphBlocks({ + afterBlockId, + blockType, + createBlockId, + paragraphs, +}: { + afterBlockId: string; + blockType: string; + createBlockId: () => string; + paragraphs: readonly string[]; +}): InsertedParagraphBlock[] { + const blocks: InsertedParagraphBlock[] = []; + let previousBlockId = afterBlockId; + + for (const text of paragraphs) { + const blockId = createBlockId(); + blocks.push({ + afterBlockId: previousBlockId, + blockId, + blockType, + text, + }); + previousBlockId = blockId; + } + + return blocks; +} + +export function toInsertedParagraphBlockOperations( + block: InsertedParagraphBlock, +): ReplacementReviewOperation[] { + const operations: ReplacementReviewOperation[] = [ + { + type: "insert-block", + blockId: block.blockId, + blockType: block.blockType, + props: {}, + position: { after: block.afterBlockId }, + }, + ]; + + if (block.text.length > 0) { + operations.push({ + type: "insert-text", + blockId: block.blockId, + offset: 0, + text: block.text, + }); + } + + return operations; +} diff --git a/packages/extensions/ai/src/suggestions/replacementPlan/replacementRange.ts b/packages/extensions/ai/src/suggestions/replacementPlan/replacementRange.ts new file mode 100644 index 0000000..2b707ac --- /dev/null +++ b/packages/extensions/ai/src/suggestions/replacementPlan/replacementRange.ts @@ -0,0 +1,118 @@ +import { splitPlainTextBlocks } from "@pen/content-ops"; +import { hasLineBreak } from "./sharedTextDiff"; + +export interface ReplacementRangeBlock { + id: string; + text: string; +} + +export interface ReplacementRangePoint { + blockId: string; + offset: number; +} + +export interface ReplacementRange { + start: ReplacementRangePoint; + end: ReplacementRangePoint; +} + +export interface NormalizedReplacementRange { + start: ReplacementRangePoint; + end: ReplacementRangePoint; + startBlock: ReplacementRangeBlock; + endBlock: ReplacementRangeBlock; + middleBlocks: ReplacementRangeBlock[]; +} + +export interface RangeTextFragment { + blockId: string; + offset: number; + text: string; +} + +export function normalizeReplacementRange( + range: ReplacementRange, + blocks: readonly ReplacementRangeBlock[], +): NormalizedReplacementRange { + const startIndex = blocks.findIndex((block) => block.id === range.start.blockId); + const endIndex = blocks.findIndex((block) => block.id === range.end.blockId); + if (startIndex < 0 || endIndex < 0) { + throw new Error("Replacement range block was not found in the document."); + } + + const isForward = + startIndex < endIndex || + (startIndex === endIndex && range.start.offset <= range.end.offset); + const fromIndex = Math.min(startIndex, endIndex); + const toIndex = Math.max(startIndex, endIndex); + const start = isForward ? range.start : range.end; + const end = isForward ? range.end : range.start; + const startBlock = blocks[fromIndex]!; + const endBlock = blocks[toIndex]!; + const middleBlocks = blocks.slice(fromIndex + 1, toIndex); + + return { + start, + end, + startBlock, + endBlock, + middleBlocks, + }; +} + +export function resolveSelectedRangeTextFragments( + normalizedRange: NormalizedReplacementRange, +): RangeTextFragment[] { + return [ + { + blockId: normalizedRange.start.blockId, + offset: normalizedRange.start.offset, + text: normalizedRange.startBlock.text.slice(normalizedRange.start.offset), + }, + ...normalizedRange.middleBlocks.map((block) => ({ + blockId: block.id, + offset: 0, + text: block.text, + })), + { + blockId: normalizedRange.end.blockId, + offset: 0, + text: normalizedRange.endBlock.text.slice(0, normalizedRange.end.offset), + }, + ]; +} + +export function splitReplacementParagraphs(text: string): string[] | undefined { + if (!hasLineBreak(text)) { + return undefined; + } + + return splitPlainTextBlocks(text); +} + +export function readBlockRangeOriginalText( + normalizedRange: NormalizedReplacementRange, +): string { + if ( + normalizedRange.start.blockId === normalizedRange.end.blockId && + normalizedRange.middleBlocks.length === 0 + ) { + const from = Math.min( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + const to = Math.max( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + return normalizedRange.startBlock.text.slice(from, to); + } + + const segments = [ + normalizedRange.startBlock.text.slice(normalizedRange.start.offset), + ...normalizedRange.middleBlocks.map((block) => block.text), + normalizedRange.endBlock.text.slice(0, normalizedRange.end.offset), + ]; + + return segments.join("\n"); +} diff --git a/packages/extensions/ai/src/suggestions/replacementPlan/sharedTextDiff.ts b/packages/extensions/ai/src/suggestions/replacementPlan/sharedTextDiff.ts new file mode 100644 index 0000000..b6ca869 --- /dev/null +++ b/packages/extensions/ai/src/suggestions/replacementPlan/sharedTextDiff.ts @@ -0,0 +1,45 @@ +export function countSharedPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +export function countSharedSuffixLength(left: string, right: string): number { + let count = 0; + while ( + count < left.length && + count < right.length && + left[left.length - count - 1] === right[right.length - count - 1] + ) { + count += 1; + } + return count; +} + +export function findStreamingPreviewResyncAnchor( + originalTail: string, + replacementTail: string, +): { originalOffset: number; replacementOffset: number } | null { + for ( + let replacementOffset = 0; + replacementOffset < replacementTail.length; + replacementOffset += 1 + ) { + const candidate = replacementTail.slice(replacementOffset); + if (candidate.trim().length < 3) { + continue; + } + const originalOffset = originalTail.indexOf(candidate); + if (originalOffset >= 0) { + return { originalOffset, replacementOffset }; + } + } + + return null; +} + +export function hasLineBreak(text: string): boolean { + return /[\r\n]/.test(text); +} diff --git a/packages/extensions/ai/src/suggestions/replacementPlan/streamingPreviewPlan.ts b/packages/extensions/ai/src/suggestions/replacementPlan/streamingPreviewPlan.ts new file mode 100644 index 0000000..d44c17f --- /dev/null +++ b/packages/extensions/ai/src/suggestions/replacementPlan/streamingPreviewPlan.ts @@ -0,0 +1,406 @@ +import type { Editor } from "@pen/types"; +import type { AIStreamingReviewPreview } from "../../types"; +import { + normalizeReplacementRange, + readBlockRangeOriginalText, + resolveSelectedRangeTextFragments, + splitReplacementParagraphs, + type NormalizedReplacementRange, + type ReplacementRangeBlock, +} from "./replacementRange"; +import { + countSharedPrefixLength, + countSharedSuffixLength, + findStreamingPreviewResyncAnchor, +} from "./sharedTextDiff"; + +export interface TextRangeStreamingPreviewPlan { + kind: "text-range"; + blockId: string; + deleteFrom: number; + deleteTo: number; + insertedTextStart: number; + insertOffset: number; + text: string; +} + +export interface BlockRangeStreamingPreviewPlan { + kind: "block-range"; + normalizedRange: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + middleBlockIds: string[]; + }; + deleteFromChar: number; + deleteToChar: number; + insertedTextStart: number; + insertText: string; +} + +export interface AlignedBlockRangeStreamingPreviewPlan { + kind: "aligned-block-range"; + plans: TextRangeStreamingPreviewPlan[]; +} + +export type StreamingPreviewPlanResult = + | TextRangeStreamingPreviewPlan + | BlockRangeStreamingPreviewPlan + | AlignedBlockRangeStreamingPreviewPlan; + +export function buildStreamingPreviewPlan( + editor: Editor, + preview: AIStreamingReviewPreview, +): StreamingPreviewPlanResult | null { + if (preview.target.kind === "text-range") { + return buildTextRangeStreamingPreviewPlan(editor, preview); + } + + if (preview.target.kind === "block-range") { + return buildBlockRangeStreamingPreviewPlan(editor, preview); + } + + return null; +} + +function buildTextRangeStreamingPreviewPlan( + editor: Editor, + preview: AIStreamingReviewPreview, +): TextRangeStreamingPreviewPlan | null { + if (preview.target.kind !== "text-range") { + return null; + } + + const block = editor.getBlock(preview.target.blockId); + if (!block) { + return null; + } + + const from = Math.min(preview.target.from, preview.target.to); + const to = Math.max(preview.target.from, preview.target.to); + const originalText = block.textContent().slice(from, to); + return buildStreamingTextPreviewPlan({ + blockId: preview.target.blockId, + from, + insertedTextStartOffset: 0, + originalText, + previousTextLength: preview.previousTextLength, + replacementText: preview.text, + to, + }); +} + +function buildBlockRangeStreamingPreviewPlan( + editor: Editor, + preview: AIStreamingReviewPreview, +): StreamingPreviewPlanResult | null { + if (preview.target.kind !== "block-range") { + return null; + } + + const normalizedRange = normalizeStreamingBlockRange(editor, preview.target); + if (!normalizedRange) { + return null; + } + + const normalizedReplacementRange = toNormalizedReplacementRange( + editor, + normalizedRange, + ); + const originalText = readBlockRangeOriginalText(normalizedReplacementRange); + if (originalText.length === 0 && preview.text.length === 0) { + return null; + } + + const alignedPlan = buildAlignedBlockRangeStreamingPreviewPlan( + editor, + normalizedRange, + preview.text, + ); + if (alignedPlan) { + return alignedPlan; + } + + const partialPlan = buildPartialBlockRangeStreamingPreviewPlan({ + normalizedRange, + originalText, + previousTextLength: preview.previousTextLength, + replacementText: preview.text, + }); + if (partialPlan) { + return partialPlan; + } + + const sharedPrefixLength = countSharedPrefixLength(originalText, preview.text); + const originalTail = originalText.slice(sharedPrefixLength); + const previewTail = preview.text.slice(sharedPrefixLength); + const sharedSuffixLength = countSharedSuffixLength(originalTail, previewTail); + const deleteFromChar = sharedPrefixLength; + const deleteToChar = originalText.length - sharedSuffixLength; + const insertText = preview.text.slice( + sharedPrefixLength, + preview.text.length - sharedSuffixLength, + ); + + if ( + deleteFromChar === deleteToChar && + insertText.length === 0 && + sharedPrefixLength === 0 && + sharedSuffixLength === 0 + ) { + return null; + } + + return { + kind: "block-range", + normalizedRange, + deleteFromChar, + deleteToChar, + insertedTextStart: sharedPrefixLength, + insertText, + }; +} + +function buildPartialBlockRangeStreamingPreviewPlan({ + normalizedRange, + originalText, + previousTextLength, + replacementText, +}: { + normalizedRange: BlockRangeStreamingPreviewPlan["normalizedRange"]; + originalText: string; + previousTextLength: number; + replacementText: string; +}): BlockRangeStreamingPreviewPlan | null { + if (replacementText.length >= originalText.length) { + return null; + } + + const sharedPrefixLength = countSharedPrefixLength(originalText, replacementText); + if (sharedPrefixLength === 0) { + return null; + } + if (sharedPrefixLength >= replacementText.length) { + return previousTextLength < replacementText.length + ? { + kind: "block-range", + normalizedRange, + deleteFromChar: sharedPrefixLength, + deleteToChar: sharedPrefixLength, + insertedTextStart: sharedPrefixLength, + insertText: "", + } + : null; + } + + const originalTail = originalText.slice(sharedPrefixLength); + const replacementTail = replacementText.slice(sharedPrefixLength); + const anchor = findStreamingPreviewResyncAnchor(originalTail, replacementTail); + + return { + kind: "block-range", + normalizedRange, + deleteFromChar: sharedPrefixLength, + deleteToChar: sharedPrefixLength + (anchor?.originalOffset ?? 0), + insertedTextStart: sharedPrefixLength, + insertText: replacementTail.slice( + 0, + anchor?.replacementOffset ?? replacementTail.length, + ), + }; +} + +function buildAlignedBlockRangeStreamingPreviewPlan( + editor: Editor, + normalizedRange: BlockRangeStreamingPreviewPlan["normalizedRange"], + replacementText: string, +): AlignedBlockRangeStreamingPreviewPlan | null { + const normalizedReplacementRange = toNormalizedReplacementRange( + editor, + normalizedRange, + ); + const fragments = resolveSelectedRangeTextFragments(normalizedReplacementRange); + const replacementParagraphs = splitReplacementParagraphs(replacementText); + if ( + !replacementParagraphs || + fragments.length !== replacementParagraphs.length || + fragments.some((fragment) => fragment.text.length === 0) + ) { + return null; + } + + let paragraphStartOffset = 0; + const plans: TextRangeStreamingPreviewPlan[] = []; + for (let index = 0; index < fragments.length; index += 1) { + const fragment = fragments[index]!; + const paragraph = replacementParagraphs[index] ?? ""; + const plan = buildStreamingTextPreviewPlan({ + blockId: fragment.blockId, + from: fragment.offset, + insertedTextStartOffset: paragraphStartOffset, + originalText: fragment.text, + previousTextLength: Number.POSITIVE_INFINITY, + replacementText: paragraph, + to: fragment.offset + fragment.text.length, + }); + if (plan.deleteTo > plan.deleteFrom || plan.text.length > 0) { + plans.push(plan); + } + paragraphStartOffset += paragraph.length + 1; + } + + return plans.length > 0 ? { kind: "aligned-block-range", plans } : null; +} + +function buildStreamingTextPreviewPlan({ + blockId, + from, + insertedTextStartOffset, + originalText, + previousTextLength, + replacementText, + to, +}: { + blockId: string; + from: number; + insertedTextStartOffset: number; + originalText: string; + previousTextLength: number; + replacementText: string; + to: number; +}): TextRangeStreamingPreviewPlan { + const partialPlan = buildPartialStreamingTextPreviewPlan({ + blockId, + from, + insertedTextStartOffset, + originalText, + previousTextLength, + replacementText, + }); + if (partialPlan) { + return partialPlan; + } + + const sharedPrefixLength = countSharedPrefixLength(originalText, replacementText); + const originalTail = originalText.slice(sharedPrefixLength); + const previewTail = replacementText.slice(sharedPrefixLength); + const sharedSuffixLength = countSharedSuffixLength(originalTail, previewTail); + const insertedTextEnd = replacementText.length - sharedSuffixLength; + + return { + kind: "text-range", + blockId, + deleteFrom: from + sharedPrefixLength, + deleteTo: to - sharedSuffixLength, + insertedTextStart: insertedTextStartOffset + sharedPrefixLength, + insertOffset: from + sharedPrefixLength, + text: replacementText.slice(sharedPrefixLength, insertedTextEnd), + }; +} + +function buildPartialStreamingTextPreviewPlan({ + blockId, + from, + insertedTextStartOffset, + originalText, + previousTextLength, + replacementText, +}: { + blockId: string; + from: number; + insertedTextStartOffset: number; + originalText: string; + previousTextLength: number; + replacementText: string; +}): TextRangeStreamingPreviewPlan | null { + if (replacementText.length >= originalText.length) { + return null; + } + + const sharedPrefixLength = countSharedPrefixLength(originalText, replacementText); + if (sharedPrefixLength === 0) { + return null; + } + if (sharedPrefixLength >= replacementText.length) { + return previousTextLength < replacementText.length + ? { + kind: "text-range", + blockId, + deleteFrom: from + sharedPrefixLength, + deleteTo: from + sharedPrefixLength, + insertedTextStart: insertedTextStartOffset + sharedPrefixLength, + insertOffset: from + sharedPrefixLength, + text: "", + } + : null; + } + + const originalTail = originalText.slice(sharedPrefixLength); + const replacementTail = replacementText.slice(sharedPrefixLength); + const anchor = findStreamingPreviewResyncAnchor(originalTail, replacementTail); + + return { + kind: "text-range", + blockId, + deleteFrom: from + sharedPrefixLength, + deleteTo: from + sharedPrefixLength + (anchor?.originalOffset ?? 0), + insertedTextStart: insertedTextStartOffset + sharedPrefixLength, + insertOffset: from + sharedPrefixLength, + text: replacementTail.slice( + 0, + anchor?.replacementOffset ?? replacementTail.length, + ), + }; +} + +export function normalizeStreamingBlockRange( + editor: Editor, + target: Extract, +): BlockRangeStreamingPreviewPlan["normalizedRange"] | null { + const blockIds = target.blockIds.filter((blockId) => editor.getBlock(blockId)); + const startIndex = blockIds.indexOf(target.start.blockId); + const endIndex = blockIds.indexOf(target.end.blockId); + if (startIndex < 0 || endIndex < 0) { + return null; + } + + const isForward = + startIndex < endIndex || + (startIndex === endIndex && target.start.offset <= target.end.offset); + const fromIndex = Math.min(startIndex, endIndex); + const toIndex = Math.max(startIndex, endIndex); + return { + start: isForward ? target.start : target.end, + end: isForward ? target.end : target.start, + middleBlockIds: blockIds.slice(fromIndex + 1, toIndex), + }; +} + +function toNormalizedReplacementRange( + editor: Editor, + normalizedRange: BlockRangeStreamingPreviewPlan["normalizedRange"], +): NormalizedReplacementRange { + const blocks: ReplacementRangeBlock[] = [ + { + id: normalizedRange.start.blockId, + text: editor.getBlock(normalizedRange.start.blockId)?.textContent() ?? "", + }, + ...normalizedRange.middleBlockIds.map((blockId) => ({ + id: blockId, + text: editor.getBlock(blockId)?.textContent() ?? "", + })), + ]; + if (normalizedRange.end.blockId !== normalizedRange.start.blockId) { + blocks.push({ + id: normalizedRange.end.blockId, + text: editor.getBlock(normalizedRange.end.blockId)?.textContent() ?? "", + }); + } + + return normalizeReplacementRange( + { + start: normalizedRange.start, + end: normalizedRange.end, + }, + blocks, + ); +} diff --git a/packages/extensions/ai/src/suggestions/replacementPlan/textDiffEngine.ts b/packages/extensions/ai/src/suggestions/replacementPlan/textDiffEngine.ts new file mode 100644 index 0000000..83a6008 --- /dev/null +++ b/packages/extensions/ai/src/suggestions/replacementPlan/textDiffEngine.ts @@ -0,0 +1,374 @@ +import type { DocumentOp } from "@pen/types"; + +export type ReplacementTextDiffOperation = Extract< + DocumentOp, + { type: "delete-text" | "insert-text" | "replace-text" } +>; + +export type TextToken = { + text: string; + start: number; + end: number; +}; + +export type DiffHunk = { + originalStart: number; + deletedText: string; + insertedText: string; +}; + +export const DEFAULT_MAX_DIFF_CELLS = 20_000; + +const NOISY_REPLACEMENT_MIN_TEXT_LENGTH = 80; +const NOISY_REPLACEMENT_MIN_HUNKS = 4; +const NOISY_REPLACEMENT_MIN_CHANGED_RATIO = 0.45; + +export interface CompileReplacementSuggestionOpsInput { + blockId: string; + offset: number; + originalText: string; + replacementText: string; + maxDiffCells?: number; +} + +export function compileReplacementSuggestionOps({ + blockId, + offset, + originalText, + replacementText, + maxDiffCells = DEFAULT_MAX_DIFF_CELLS, +}: CompileReplacementSuggestionOpsInput): ReplacementTextDiffOperation[] { + if (originalText === replacementText) { + return []; + } + + if (originalText.length === 0) { + return replacementText.length === 0 + ? [] + : [{ type: "insert-text", blockId, offset, text: replacementText }]; + } + + if (replacementText.length === 0) { + return [ + { + type: "delete-text", + blockId, + offset, + length: originalText.length, + }, + ]; + } + + const originalTokens = tokenizeText(originalText); + const replacementTokens = tokenizeText(replacementText); + if ( + originalTokens.length === 0 || + replacementTokens.length === 0 || + originalTokens.length * replacementTokens.length > maxDiffCells + ) { + return [ + { + type: "replace-text", + blockId, + offset, + length: originalText.length, + text: replacementText, + }, + ]; + } + + const hunks = diffTokens(originalTokens, replacementTokens); + if (shouldUseCoarseReplacement({ hunks, originalText, replacementText })) { + return [ + { + type: "replace-text", + blockId, + offset, + length: originalText.length, + text: replacementText, + }, + ]; + } + return hunksToOperations({ blockId, hunks: [...hunks].reverse(), offset }); +} + +export function tokenizeText(text: string): TextToken[] { + const tokens: TextToken[] = []; + let index = 0; + + while (index < text.length) { + const start = index; + const char = text[index] ?? ""; + + if (char === "\r" || char === "\n") { + if (char === "\r" && text[index + 1] === "\n") { + index += 2; + } else { + index += 1; + } + tokens.push({ text: text.slice(start, index), start, end: index }); + continue; + } + + if (isWhitespace(char)) { + index += 1; + while ( + index < text.length && + isWhitespace(text[index] ?? "") && + text[index] !== "\r" && + text[index] !== "\n" + ) { + index += 1; + } + tokens.push({ text: text.slice(start, index), start, end: index }); + continue; + } + + if (isWordChar(char)) { + index += 1; + while (index < text.length && isWordChar(text[index] ?? "")) { + index += 1; + } + tokens.push({ text: text.slice(start, index), start, end: index }); + continue; + } + + index += 1; + while ( + index < text.length && + !isWhitespace(text[index] ?? "") && + !isWordChar(text[index] ?? "") + ) { + index += 1; + } + tokens.push({ text: text.slice(start, index), start, end: index }); + } + + return tokens; +} + +export function diffTokens( + originalTokens: readonly TextToken[], + replacementTokens: readonly TextToken[], +): DiffHunk[] { + const prefixLength = countSharedPrefix(originalTokens, replacementTokens); + const suffixLength = countSharedSuffix( + originalTokens, + replacementTokens, + prefixLength, + ); + const originalOffset = + originalTokens[prefixLength]?.start ?? + (prefixLength > 0 ? originalTokens[prefixLength - 1]!.end : 0); + const originalMiddle = originalTokens + .slice(prefixLength, originalTokens.length - suffixLength) + .map((token) => ({ + ...token, + start: token.start - originalOffset, + end: token.end - originalOffset, + })); + const replacementMiddle = replacementTokens.slice( + prefixLength, + replacementTokens.length - suffixLength, + ); + const middleHunks = diffTokenMiddle(originalMiddle, replacementMiddle); + + return middleHunks.map((hunk) => ({ + ...hunk, + originalStart: hunk.originalStart + originalOffset, + })); +} + +export function diffTokenMiddle( + originalTokens: readonly TextToken[], + replacementTokens: readonly TextToken[], +): DiffHunk[] { + const rowCount = originalTokens.length + 1; + const columnCount = replacementTokens.length + 1; + const lcs: number[][] = Array.from({ length: rowCount }, () => + Array(columnCount).fill(0), + ); + + for ( + let originalIndex = originalTokens.length - 1; + originalIndex >= 0; + originalIndex -= 1 + ) { + for ( + let replacementIndex = replacementTokens.length - 1; + replacementIndex >= 0; + replacementIndex -= 1 + ) { + lcs[originalIndex]![replacementIndex] = + originalTokens[originalIndex]!.text === + replacementTokens[replacementIndex]!.text + ? lcs[originalIndex + 1]![replacementIndex + 1]! + 1 + : Math.max( + lcs[originalIndex + 1]![replacementIndex]!, + lcs[originalIndex]![replacementIndex + 1]!, + ); + } + } + + const hunks: DiffHunk[] = []; + let current: DiffHunk | null = null; + let originalIndex = 0; + let replacementIndex = 0; + let originalCursor = 0; + + const flush = () => { + if ( + current && + (current.deletedText.length > 0 || current.insertedText.length > 0) + ) { + hunks.push(current); + } + current = null; + }; + + while ( + originalIndex < originalTokens.length || + replacementIndex < replacementTokens.length + ) { + const originalToken = originalTokens[originalIndex]; + const replacementToken = replacementTokens[replacementIndex]; + + if ( + originalToken && + replacementToken && + originalToken.text === replacementToken.text + ) { + flush(); + originalCursor = originalToken.end; + originalIndex += 1; + replacementIndex += 1; + continue; + } + + if (!current) { + current = { + originalStart: originalToken?.start ?? originalCursor, + deletedText: "", + insertedText: "", + }; + } + + if ( + !originalToken || + (replacementToken && + lcs[originalIndex]![replacementIndex + 1]! >= + lcs[originalIndex + 1]![replacementIndex]!) + ) { + current.insertedText += replacementToken?.text ?? ""; + replacementIndex += 1; + continue; + } + + current.deletedText += originalToken.text; + originalCursor = originalToken.end; + originalIndex += 1; + } + flush(); + + return hunks; +} + +export function hunksToOperations({ + blockId, + hunks, + offset, +}: { + blockId: string; + hunks: readonly DiffHunk[]; + offset: number; +}): ReplacementTextDiffOperation[] { + const operations: ReplacementTextDiffOperation[] = []; + for (const hunk of hunks) { + const deleteOffset = offset + hunk.originalStart; + if (hunk.deletedText.length > 0) { + operations.push({ + type: "delete-text", + blockId, + offset: deleteOffset, + length: hunk.deletedText.length, + }); + } + if (hunk.insertedText.length > 0) { + operations.push({ + type: "insert-text", + blockId, + offset: deleteOffset + hunk.deletedText.length, + text: hunk.insertedText, + }); + } + } + return operations; +} + +export function shouldUseCoarseReplacement({ + hunks, + originalText, + replacementText, +}: { + hunks: readonly DiffHunk[]; + originalText: string; + replacementText: string; +}): boolean { + if ( + Math.max(originalText.length, replacementText.length) < + NOISY_REPLACEMENT_MIN_TEXT_LENGTH || + hunks.length < NOISY_REPLACEMENT_MIN_HUNKS + ) { + return false; + } + + const changedLength = hunks.reduce( + (total, hunk) => + total + hunk.deletedText.length + hunk.insertedText.length, + 0, + ); + const changedRatio = + changedLength / Math.max(originalText.length, replacementText.length); + + return changedRatio >= NOISY_REPLACEMENT_MIN_CHANGED_RATIO; +} + +function countSharedPrefix( + left: readonly TextToken[], + right: readonly TextToken[], +): number { + let index = 0; + while ( + index < left.length && + index < right.length && + left[index]!.text === right[index]!.text + ) { + index += 1; + } + return index; +} + +function countSharedSuffix( + left: readonly TextToken[], + right: readonly TextToken[], + prefixLength: number, +): number { + let count = 0; + while ( + left.length - count > prefixLength && + right.length - count > prefixLength && + left[left.length - count - 1]!.text === + right[right.length - count - 1]!.text + ) { + count += 1; + } + return count; +} + +function isWhitespace(char: string): boolean { + return /\s/u.test(char); +} + +function isWordChar(char: string): boolean { + return /[\p{L}\p{N}'’]/u.test(char); +} diff --git a/packages/extensions/ai/src/suggestions/suggestMode.ts b/packages/extensions/ai/src/suggestions/suggestMode.ts index c20d0a8..4c1e315 100644 --- a/packages/extensions/ai/src/suggestions/suggestMode.ts +++ b/packages/extensions/ai/src/suggestions/suggestMode.ts @@ -1,6 +1,12 @@ -import type { DocumentOp, Editor } from "@pen/types"; -import { createSuggestionMark } from "./persistent"; -import type { BlockSuggestionMeta } from "../types"; +import type { DocumentOp, Editor, OpOrigin } from "@pen/types"; +import { getOpOriginType } from "@pen/types"; +import { + createSuggestionMark, + serializeBlockSuggestionMeta, + type BlockSuggestionMetaPayload, + type SuggestionCreationOptions, +} from "./persistent"; +import type { BlockSuggestionMeta, PersistentSuggestion } from "../types"; export const SUGGESTION_RESOLUTION_ORIGIN = "suggestion-resolution"; export const AI_SESSION_SUGGESTION_ORIGIN = "ai-session"; @@ -15,8 +21,8 @@ const BYPASS_ORIGINS = new Set([ SUGGESTION_RESOLUTION_ORIGIN, ]); -export function shouldBypassSuggestMode(origin?: string): boolean { - return origin != null && BYPASS_ORIGINS.has(origin); +export function shouldBypassSuggestMode(origin?: OpOrigin): boolean { + return origin != null && BYPASS_ORIGINS.has(getOpOriginType(origin)); } export function interceptApplyForSuggestMode( @@ -26,12 +32,108 @@ export function interceptApplyForSuggestMode( authorType: "user" | "ai", model?: string, sessionId?: string, + options: SuggestModeSuggestionOptions = {}, ): DocumentOp[] { + return interceptApplyForSuggestModeWithMetadata( + ops, + editor, + author, + authorType, + model, + sessionId, + options, + ).operations; +} + +export type InterceptApplyForSuggestModeResult = { + operations: DocumentOp[]; + suggestionIds: string[]; + suggestions: PersistentSuggestion[]; +}; + +export function interceptApplyForSuggestModeWithMetadata( + ops: DocumentOp[], + editor: Editor, + author: string, + authorType: "user" | "ai", + model?: string, + sessionId?: string, + options: SuggestModeSuggestionOptions = {}, +): InterceptApplyForSuggestModeResult { const intercepted: DocumentOp[] = []; + const suggestions: PersistentSuggestion[] = []; + let suggestionIdIndex = 0; + const nextSuggestionOptions = (): RequiredSuggestionCreationOptions => { + const suggestionId = + options.suggestionIds?.[suggestionIdIndex] ?? crypto.randomUUID(); + suggestionIdIndex += 1; + return { + requestId: options.requestId, + sessionId, + turnId: options.turnId, + generationId: options.generationId, + createdAt: options.createdAt ?? Date.now(), + suggestionId, + }; + }; + const pushTextSuggestion = ( + action: "insert" | "delete", + blockId: string, + offset: number, + length: number, + suggestionOptions: RequiredSuggestionCreationOptions, + ) => { + suggestions.push({ + kind: "text", + id: suggestionOptions.suggestionId, + action, + author, + authorType, + createdAt: suggestionOptions.createdAt, + model, + sessionId: suggestionOptions.sessionId, + requestId: suggestionOptions.requestId, + turnId: suggestionOptions.turnId, + generationId: suggestionOptions.generationId, + blockId, + offset, + length, + }); + }; + const pushBlockSuggestion = ( + action: BlockSuggestionMeta["action"], + blockId: string, + previousState: BlockSuggestionMeta["previousState"], + suggestionOptions: RequiredSuggestionCreationOptions, + ) => { + suggestions.push({ + kind: "block", + id: suggestionOptions.suggestionId, + action, + author, + authorType, + createdAt: suggestionOptions.createdAt, + model, + sessionId: suggestionOptions.sessionId, + requestId: suggestionOptions.requestId, + turnId: suggestionOptions.turnId, + generationId: suggestionOptions.generationId, + blockId, + previousState, + }); + }; for (const op of ops) { switch (op.type) { case "insert-text": { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "insert", + op.blockId, + op.offset, + op.text.length, + suggestionOptions, + ); intercepted.push({ ...op, marks: { @@ -42,6 +144,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + suggestionOptions, ), }, }); @@ -50,6 +153,14 @@ export function interceptApplyForSuggestMode( case "replace-text": { if (op.length > 0) { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "delete", + op.blockId, + op.offset, + op.length, + suggestionOptions, + ); intercepted.push({ type: "format-text", blockId: op.blockId, @@ -61,10 +172,19 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + suggestionOptions, ), }); } if (op.text.length > 0) { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "insert", + op.blockId, + op.offset + op.length, + op.text.length, + suggestionOptions, + ); intercepted.push({ type: "insert-text", blockId: op.blockId, @@ -78,6 +198,7 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + suggestionOptions, ), }, }); @@ -86,6 +207,14 @@ export function interceptApplyForSuggestMode( } case "delete-text": { + const suggestionOptions = nextSuggestionOptions(); + pushTextSuggestion( + "delete", + op.blockId, + op.offset, + op.length, + suggestionOptions, + ); intercepted.push({ type: "format-text", blockId: op.blockId, @@ -97,12 +226,20 @@ export function interceptApplyForSuggestMode( authorType, model, sessionId, + suggestionOptions, ), }); break; } case "insert-block": { + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "insert-block", + op.blockId, + undefined, + suggestionOptions, + ); intercepted.push(op); intercepted.push({ type: "set-meta", @@ -115,12 +252,20 @@ export function interceptApplyForSuggestMode( model, undefined, sessionId, + suggestionOptions, ), }); break; } case "delete-block": { + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "delete-block", + op.blockId, + undefined, + suggestionOptions, + ); intercepted.push({ type: "set-meta", blockId: op.blockId, @@ -132,6 +277,7 @@ export function interceptApplyForSuggestMode( model, undefined, sessionId, + suggestionOptions, ), }); break; @@ -140,6 +286,23 @@ export function interceptApplyForSuggestMode( case "move-block": { const block = editor.getBlock(op.blockId); const layoutParent = block?.layoutParent(); + const previousState: BlockSuggestionMeta["previousState"] = { + position: layoutParent + ? { + parent: layoutParent.id, + index: block?.index ?? 0, + } + : block?.prev + ? { after: block.prev.id } + : "first", + }; + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "move-block", + op.blockId, + previousState, + suggestionOptions, + ); intercepted.push(op); intercepted.push({ type: "set-meta", @@ -150,14 +313,9 @@ export function interceptApplyForSuggestMode( author, authorType, model, - { - position: layoutParent - ? { parent: layoutParent.id, index: block?.index ?? 0 } - : block?.prev - ? { after: block.prev.id } - : "first", - }, + previousState, sessionId, + suggestionOptions, ), }); break; @@ -165,6 +323,17 @@ export function interceptApplyForSuggestMode( case "convert-block": { const block = editor.getBlock(op.blockId); + const previousState: BlockSuggestionMeta["previousState"] = { + type: block?.type, + props: block ? { ...block.props } : undefined, + }; + const suggestionOptions = nextSuggestionOptions(); + pushBlockSuggestion( + "convert-block", + op.blockId, + previousState, + suggestionOptions, + ); intercepted.push(op); intercepted.push({ type: "set-meta", @@ -175,11 +344,9 @@ export function interceptApplyForSuggestMode( author, authorType, model, - { - type: block?.type, - props: block ? { ...block.props } : undefined, - }, + previousState, sessionId, + suggestionOptions, ), }); break; @@ -190,9 +357,26 @@ export function interceptApplyForSuggestMode( } } - return intercepted; + return { + operations: intercepted, + suggestionIds: suggestions.map((suggestion) => suggestion.id), + suggestions, + }; } +export type SuggestModeSuggestionOptions = { + requestId?: string; + turnId?: string; + generationId?: string; + createdAt?: number; + suggestionIds?: readonly string[]; +}; + +type RequiredSuggestionCreationOptions = SuggestionCreationOptions & { + suggestionId: string; + createdAt: number; +}; + function createBlockSuggestionMeta( action: BlockSuggestionMeta["action"], author: string, @@ -200,15 +384,21 @@ function createBlockSuggestionMeta( model?: string, previousState?: BlockSuggestionMeta["previousState"], sessionId?: string, -): Record { - return { - id: crypto.randomUUID(), + options: SuggestionCreationOptions = {}, +): BlockSuggestionMetaPayload { + const resolvedSessionId = options.sessionId ?? sessionId; + const meta: BlockSuggestionMeta = { + id: options.suggestionId ?? crypto.randomUUID(), action, author, authorType, - createdAt: Date.now(), + createdAt: options.createdAt ?? Date.now(), model, previousState, - sessionId, + sessionId: resolvedSessionId, + requestId: options.requestId, + turnId: options.turnId, + generationId: options.generationId, }; + return serializeBlockSuggestionMeta(meta); } diff --git a/packages/extensions/ai/src/suggestions/textDiffOperations.ts b/packages/extensions/ai/src/suggestions/textDiffOperations.ts new file mode 100644 index 0000000..494a1d4 --- /dev/null +++ b/packages/extensions/ai/src/suggestions/textDiffOperations.ts @@ -0,0 +1,78 @@ +import { + normalizeReplacementRange, + type ReplacementRangeBlock, +} from "./replacementPlan/replacementRange"; +import { + buildMultiBlockReplacementOperations, + buildSingleBlockReplacementOperations, + createDefaultReplacementBlockId, + DEFAULT_INSERTED_BLOCK_TYPE, + type ReplacementReviewOperation, +} from "./replacementPlan/rangeReplacementOps"; +import { + compileReplacementSuggestionOps, + type CompileReplacementSuggestionOpsInput, + type ReplacementTextDiffOperation, +} from "./replacementPlan/textDiffEngine"; + +export type { ReplacementRangeBlock } from "./replacementPlan/replacementRange"; +export type { + CompileReplacementSuggestionOpsInput, + ReplacementTextDiffOperation, +} from "./replacementPlan/textDiffEngine"; +export type { ReplacementReviewOperation } from "./replacementPlan/rangeReplacementOps"; +export { compileReplacementSuggestionOps } from "./replacementPlan/textDiffEngine"; + +export interface CompileRangeReplacementSuggestionOpsInput { + range: { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + }; + blocks: readonly ReplacementRangeBlock[]; + replacementText: string; + blockType?: string; + createBlockId?: () => string; + maxDiffCells?: number; +} + +export function compileRangeReplacementSuggestionOps({ + range, + blocks, + replacementText, + blockType = DEFAULT_INSERTED_BLOCK_TYPE, + createBlockId = createDefaultReplacementBlockId, + maxDiffCells, +}: CompileRangeReplacementSuggestionOpsInput): ReplacementReviewOperation[] { + const normalizedRange = normalizeReplacementRange(range, blocks); + if (normalizedRange.start.blockId === normalizedRange.end.blockId) { + const offset = Math.min( + normalizedRange.start.offset, + normalizedRange.end.offset, + ); + const length = Math.abs( + normalizedRange.end.offset - normalizedRange.start.offset, + ); + const originalText = normalizedRange.startBlock.text.slice( + offset, + offset + length, + ); + + return buildSingleBlockReplacementOperations({ + blockId: normalizedRange.start.blockId, + blockType, + createBlockId, + maxDiffCells, + offset, + originalText, + replacementText, + }); + } + + return buildMultiBlockReplacementOperations({ + blockType, + createBlockId, + maxDiffCells, + normalizedRange, + replacementText, + }); +} diff --git a/packages/extensions/ai/src/typeParts/typesPart1.ts b/packages/extensions/ai/src/typeParts/typesPart1.ts new file mode 100644 index 0000000..340cbdd --- /dev/null +++ b/packages/extensions/ai/src/typeParts/typesPart1.ts @@ -0,0 +1,458 @@ +import type { + Editor, + DocumentOp, + InlineCompletionController as CoreInlineCompletionController, + InlineCompletionState as CoreInlineCompletionState, + ModelAdapter, + ModelMessage, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + ModelRequestedOperation, + SelectionState, + TextSelection, + ToolRuntime, +} from "@pen/types"; +import type { + AIApplyStrategy, + AIMutationMode, + AIRouteLane, + AIContentFormat, + AIBlockAdapterId, + AIBlockClass, + AIExecutionMode, + AIPlannerMode, + AIQualityMetricId, + AITargetKind, + AITransportKind, + AIWorkingSetViewMode, +} from "../runtime/contracts"; +import type { DocumentMutationPlan } from "../runtime/planTypes"; +import type { FlowPatchAlignmentMetrics } from "../runtime/planExecutor"; +import type { + StructuralReviewItem, + StructuredPreviewTargetState, +} from "../runtime/reviewArtifacts"; +import type { StructuredIntent } from "../runtime/structuredIntent"; +import type { + PersistentBlockSuggestion, + PersistentSuggestion, + BlockSuggestionMeta, + AIAwarenessState, + AICommandContext, + AICommandGuard, + AICommandBinding, + AIControllerState, + AIPromptTarget, + AISessionResolution, + AIInlineHistoryDirection, + AIInlineHistoryController, + AIReviewController, + AICommandExecutionOptions, + AIRequestedOperation, + AIController, + AgenticLoopOptions, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + GenerationDebugState, + StructuredGenerationDebugState, + FastApplyDebugState, + FastApplyFallbackMetrics, + AIMutationReceiptStatus, + AIMutationReceiptEvidence, + AIMutationReceipt, +} from "./typesPart2"; + +export interface AIExtensionConfig { + model?: ModelAdapter; + suggestMode?: boolean; + suggestionPresentation?: AISuggestionPresentation; + commands?: AICommandBinding[]; + maxAgenticSteps?: number; + author?: string; + contentFormat?: AIContentFormatOptions; +} + +export type AISuggestionPresentation = "track-changes" | "final-text"; + +export interface AIContentFormatOptions { + blockGeneration?: AIContentFormat; + selectionRewrite?: AIContentFormat; +} + +export type ResolvedEditTarget = + | ModelOperationSelectionTarget + | ModelOperationScopedRangeTarget; + +export interface ResolvedEditProposal { + promptIntent: string; + target: ResolvedEditTarget; +} + +export type AIStatus = + | "idle" + | "reading" + | "thinking" + | "writing" + | "tool-calling"; + +export type AISurface = "inline-edit" | "bottom-chat"; + +export type AISessionStatus = + | "idle" + | "streaming" + | "paused" + | "complete" + | "cancelled" + | "error"; + +export type AISessionTarget = + | { + kind: "selection"; + selection: TextSelection; + blockId: string | null; + } + | { + kind: "block"; + blockId: string; + } + | { + kind: "document"; + }; + +export interface AISessionPrompt { + id: string; + prompt: string; + createdAt: number; + generationId?: string; + operation?: AIRequestedOperation; +} + +export interface AISessionSelectionSnapshot { + anchor: { blockId: string; offset: number }; + focus: { blockId: string; offset: number }; + blockRange: string[]; + isMultiBlock: boolean; +} + +export interface AIContextualPromptRect { + top: number; + left: number; + width: number; + height: number; +} + +export type AIContextualPromptAnchorKind = "text-range" | "block" | "document"; + +export type AIContextualPromptAnchorStatus = "valid" | "shifted" | "invalid"; + +export interface AIContextualPromptAnchor { + kind: AIContextualPromptAnchorKind; + selectionSnapshot?: AISessionSelectionSnapshot; + focusBlockId: string | null; + status: AIContextualPromptAnchorStatus; + lastResolvedRect: AIContextualPromptRect | null; +} + +export interface AIContextualPromptComposerState { + draftPrompt: string; + isOpen: boolean; + isSubmitting: boolean; + canSubmitFollowUp: boolean; + openReason?: "user" | "history"; +} + +export interface AIContextualPromptState { + anchor: AIContextualPromptAnchor; + composer: AIContextualPromptComposerState; +} + +export type AISessionTurnStatus = + | "streaming" + | "review" + | "accepted" + | "rejected" + | "complete" + | "cancelled" + | "error"; + +export interface AISessionTurn { + id: string; + prompt: string; + createdAt: number; + undoGroupId?: string; + generationId?: string; + target: Exclude; + status: AISessionTurnStatus; + suggestionIds: string[]; + reviewItemIds: string[]; + generatedBlockIds: string[]; + operation?: AIRequestedOperation; + structuredPreview?: GenerationStructuredPreviewState | null; + anchor?: AISessionAnchor; + selection?: AISessionSelectionSnapshot; +} + +export interface AISessionMetrics { + firstTokenMs?: number; + totalMs?: number; + toolMs?: number; + streamEventCount: number; + patchCount: number; + fastApply: AISessionFastApplyMetrics; +} + +export interface AISessionFastApplyMetrics { + attemptCount: number; + nativeFastApplyCount: number; + scopedReplacementCount: number; + plainMarkdownCount: number; + failedCount: number; +} + +export interface AISessionAnchor { + blockId?: string; + from?: number; + to?: number; +} + +export type AIStreamingReviewPreviewTarget = + | { + kind: "text-range"; + blockId: string; + from: number; + to: number; + } + | { + kind: "block-range"; + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + blockIds: string[]; + } + | { + kind: "insertion-point"; + blockId: string; + offset: number; + }; + +export interface AIStreamingReviewPreviewInput { + sessionId: string; + turnId?: string; + target: AIStreamingReviewPreviewTarget; + text: string; +} + +export interface AIStreamingReviewPreview extends AIStreamingReviewPreviewInput { + previousTextLength: number; + revision: number; + updatedAt: number; +} + +export interface AISession { + id: string; + surface: AISurface; + status: AISessionStatus; + target: AISessionTarget; + operation?: AIRequestedOperation | null; + contextualPrompt?: AIContextualPromptState; + turns: AISessionTurn[]; + activeTurnId?: string; + promptHistory: AISessionPrompt[]; + generationIds: string[]; + pendingSuggestionIds: string[]; + pendingReviewItemIds: string[]; + createdAt: number; + updatedAt: number; + metrics: AISessionMetrics; + anchor?: AISessionAnchor; +} + +export interface AIInlineHistorySnapshot { + id: string; + sessionId: string | null; + sessions: readonly AISession[]; + activeSessionId: string | null; + documentVersion: number; + kind: "document-coupled" | "ui-local"; +} + +export interface AIExternalInlineTurnResult { + sessionId: string; + turnId: string; + historyId: string; + operations: readonly DocumentOp[]; + suggestionIds: readonly string[]; +} + +export interface AgenticStep { + index: number; + type: "text" | "tool-call" | "tool-result"; + toolName?: string; + toolCallId?: string; + input?: unknown; + output?: unknown; + status: "pending" | "running" | "complete" | "error"; +} + +export type AIStreamEventType = + | "generation-start" + | "status" + | "text-delta" + | "operation" + | "app-partial" + | "tool-call" + | "tool-output" + | "tool-result" + | "structured-preview" + | "generation-finish"; + +export interface AIStreamEventBase { + type: AIStreamEventType; + generationId: string; + sessionId?: string; + zoneId: string; + blockId: string; + timestamp: number; +} + +export type AIStreamEvent = + | (AIStreamEventBase & { + type: "generation-start"; + prompt: string; + target: GenerationState["target"]; + }) + | (AIStreamEventBase & { + type: "status"; + status: AIStatus; + }) + | (AIStreamEventBase & { + type: "text-delta"; + delta: string; + text: string; + }) + | (AIStreamEventBase & { + type: "app-partial"; + data: unknown; + final: boolean; + }) + | (AIStreamEventBase & { + type: "tool-call"; + toolCallId: string; + toolName: string; + input: unknown; + }) + | (AIStreamEventBase & { + type: "tool-output"; + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + }) + | (AIStreamEventBase & { + type: "tool-result"; + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + }) + | (AIStreamEventBase & { + type: "structured-preview"; + preview: GenerationStructuredPreviewState; + patches: readonly StructuredPreviewPatchOperation[]; + }) + | (AIStreamEventBase & { + type: "operation"; + operation: AIRequestedOperation; + phase: "preview" | "final" | "conflict"; + text?: string; + reason?: string; + }) + | (AIStreamEventBase & { + type: "generation-finish"; + status: GenerationState["status"]; + text: string; + }); + +export interface StructuredPreviewPatchOperation { + op: "add" | "remove" | "replace"; + path: string; + value?: unknown; +} + +export interface GenerationStructuredPreviewState { + planState: "drafted" | "validated"; + plan: DocumentMutationPlan; + reviewItems: StructuralReviewItem[]; + targets: StructuredPreviewTargetState[]; +} + +export interface GenerationState { + id: string; + zoneId: string; + blockId: string; + target: "selection" | "block"; + sessionId?: string; + turnId?: string; + surface?: AISurface; + prompt: string; + operation?: AIRequestedOperation | null; + status: "streaming" | "complete" | "cancelled" | "error"; + tokenCount: number; + steps: AgenticStep[]; + undoGroupId: string; + text: string; + commandId?: string; + suggestionIds?: string[]; + route?: AIRouteLane; + mutationMode?: AIMutationMode; + contentFormat?: AIContentFormat; + applyStrategy?: AIApplyStrategy; + planState?: GenerationPlanState; + plan?: DocumentMutationPlan | null; + structuredIntent?: StructuredIntent | null; + reviewItems?: StructuralReviewItem[]; + structuredPreview?: GenerationStructuredPreviewState | null; + targetKind?: GenerationTargetKind; + blockClass?: AIBlockClass; + adapterId?: AIBlockAdapterId; + transportKind?: AITransportKind; + mutationReceipt?: AIMutationReceipt | null; + debug?: GenerationDebugState; +} + +export type GenerationPlanState = "none" | "drafted" | "validated" | "rejected"; + +export type GenerationTargetKind = AITargetKind; + +export interface EphemeralSuggestion { + id: string; + blockId: string; + offset: number; + text: string; + type: "inline" | "block"; + blockType?: string; + props?: Record; +} + +export type AIInlineCompletionState = CoreInlineCompletionState; + +export type AIInlineCompletionController = CoreInlineCompletionController; + +export interface PersistentSuggestionBase { + id: string; + author: string; + authorType: "user" | "ai"; + createdAt: number; + model?: string; + sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; + blockId: string; +} + +export interface PersistentTextSuggestion extends PersistentSuggestionBase { + kind: "text"; + action: "insert" | "delete"; + offset: number; + length: number; +} diff --git a/packages/extensions/ai/src/typeParts/typesPart2.ts b/packages/extensions/ai/src/typeParts/typesPart2.ts new file mode 100644 index 0000000..70914bb --- /dev/null +++ b/packages/extensions/ai/src/typeParts/typesPart2.ts @@ -0,0 +1,366 @@ +import type { + Editor, + InlineCompletionController as CoreInlineCompletionController, + InlineCompletionState as CoreInlineCompletionState, + ModelAdapter, + ModelMessage, + ModelOperationScopedRangeTarget, + ModelOperationSelectionTarget, + ModelRequestedOperation, + SelectionState, + TextSelection, + ToolRuntime, +} from "@pen/types"; +import type { + AIApplyStrategy, + AIMutationMode, + AIRouteLane, + AIContentFormat, + AIBlockAdapterId, + AIBlockClass, + AIExecutionMode, + AIPlannerMode, + AIQualityMetricId, + AITargetKind, + AITransportKind, + AIWorkingSetViewMode, +} from "../runtime/contracts"; +import type { DocumentMutationPlan } from "../runtime/planTypes"; +import type { FlowPatchAlignmentMetrics } from "../runtime/planExecutor"; +import type { + StructuralReviewItem, + StructuredPreviewTargetState, +} from "../runtime/reviewArtifacts"; +import type { StructuredIntent } from "../runtime/structuredIntent"; +import type { AIExtensionConfig, AIContentFormatOptions, ResolvedEditTarget, ResolvedEditProposal, AIStatus, AISurface, AISessionStatus, AISessionTarget, AISessionPrompt, AISessionSelectionSnapshot, AIContextualPromptRect, AIContextualPromptAnchorKind, AIContextualPromptAnchorStatus, AIContextualPromptAnchor, AIContextualPromptComposerState, AIContextualPromptState, AISessionTurnStatus, AISessionTurn, AISessionMetrics, AISessionFastApplyMetrics, AISessionAnchor, AISession, AIStreamingReviewPreview, AIStreamingReviewPreviewInput, AIInlineHistorySnapshot, AIExternalInlineTurnResult, AgenticStep, AIStreamEventType, AIStreamEventBase, AIStreamEvent, StructuredPreviewPatchOperation, GenerationStructuredPreviewState, GenerationState, GenerationPlanState, GenerationTargetKind, EphemeralSuggestion, AIInlineCompletionState, AIInlineCompletionController, PersistentSuggestionBase, PersistentTextSuggestion } from "./typesPart1"; + +export interface PersistentBlockSuggestion extends PersistentSuggestionBase { + kind: "block"; + action: "insert-block" | "delete-block" | "move-block" | "convert-block"; + previousState?: { + type?: string; + position?: import("@pen/types").Position; + props?: Record; + }; +} + +export type PersistentSuggestion = + | PersistentTextSuggestion + | PersistentBlockSuggestion; + +export interface BlockSuggestionMeta { + id: string; + action: "insert-block" | "delete-block" | "move-block" | "convert-block"; + author: string; + authorType: "user" | "ai"; + createdAt: number; + model?: string; + sessionId?: string; + requestId?: string; + turnId?: string; + generationId?: string; + previousState?: { + type?: string; + position?: import("@pen/types").Position; + props?: Record; + }; +} + +export interface AIAwarenessState { + status: AIStatus; + activeBlockId: string | null; + activeTool?: { name: string; toolCallId: string }; + model: string; + generationZoneId?: string; +} + +export interface AICommandContext { + editor: Editor; + selection: SelectionState; + selectedText: string; + blockType: string | null; + blockId: string | null; +} + +export type AICommandGuard = (ctx: AICommandContext) => boolean; + +export interface AICommandBinding { + id: string; + label: string; + description?: string; + icon?: string; + group?: string; + prompt: string | ((ctx: AICommandContext) => string); + guard?: AICommandGuard; + shortcut?: string; + target?: "selection" | "block"; +} + +export interface AIControllerState { + status: AIStatus; + activeGeneration: GenerationState | null; + sessions: readonly AISession[]; + activeSessionId?: string | null; + suggestMode: boolean; + ephemeralSuggestion: EphemeralSuggestion | null; + streamingReviewPreview: AIStreamingReviewPreview | null; + commandMenuOpen: boolean; + lastRoute?: AIRouteLane; +} + +export type AIPromptTarget = "auto" | "selection" | "block" | "document"; + +export type AISessionResolution = "accept" | "reject"; + +export type AIInlineHistoryDirection = "undo" | "redo"; + +export interface AIInlineHistoryController { + canUndoInlineHistory(): boolean; + canRedoInlineHistory(): boolean; + canHandleShortcut(direction: AIInlineHistoryDirection): boolean; + handleShortcut(direction: AIInlineHistoryDirection): boolean; + undoInlineHistory(): boolean; + redoInlineHistory(): boolean; +} + +export interface AIReviewController { + getSuggestions(): readonly PersistentSuggestion[]; + acceptSuggestion(id: string): boolean; + rejectSuggestion(id: string): boolean; + acceptAllSuggestions(): void; + rejectAllSuggestions(): void; +} + +export interface AICommandExecutionOptions { + blockId?: string | null; + maxSteps?: number; + target?: AIPromptTarget; + operation?: AIRequestedOperation | null; +} + +export type AIRequestedOperation = ModelRequestedOperation; + +export interface AIController { + getState(): AIControllerState; + subscribe(listener: () => void): () => void; + getSessions(): readonly AISession[]; + getActiveSession(): AISession | null; + subscribeSessions(listener: () => void): () => void; + getStreamEvents(): readonly AIStreamEvent[]; + subscribeStreamEvents(listener: () => void): () => void; + getCommands(): readonly AICommandBinding[]; + getCommandContext(): AICommandContext; + startSession(input: { + surface: AISurface; + target?: "auto" | "selection" | "block" | "document"; + }): AISession; + openContextualPrompt(input?: { + surface?: Extract; + target?: "auto" | "selection" | "block" | "document"; + }): AISession | null; + updateContextualPromptDraft(sessionId: string, draftPrompt: string): void; + setContextualPromptAnchorRect( + sessionId: string, + rect: AIContextualPromptRect | null, + ): void; + runSessionPrompt( + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): Promise; + canReuseSessionPrompt( + sessionId: string, + prompt: string, + options?: AICommandExecutionOptions, + ): boolean; + resolveSessionTurn( + sessionId: string, + turnId: string, + resolution: AISessionResolution, + ): boolean; + acceptSessionTurn(sessionId: string, turnId: string): boolean; + rejectSessionTurn(sessionId: string, turnId: string): boolean; + resolveSession(sessionId: string, resolution: AISessionResolution): boolean; + acceptSession(sessionId: string): boolean; + rejectSession(sessionId: string): boolean; + registerExternalInlineTurnResult(input: AIExternalInlineTurnResult): boolean; + cancelSession(sessionId: string): void; + suspendInlineSession(sessionId: string): void; + resumeInlineSession(sessionId: string): void; + canUndoInlineHistory(): boolean; + canRedoInlineHistory(): boolean; + undoInlineHistory(): boolean; + redoInlineHistory(): boolean; + runCommand(commandId: string, options?: AICommandExecutionOptions): Promise; + runPrompt(prompt: string, options?: AICommandExecutionOptions): Promise; + retryActiveGeneration(): Promise; + acceptActiveGeneration(): boolean; + rejectActiveGeneration(): boolean; + acceptReviewItem(id: string): boolean; + rejectReviewItem(id: string): boolean; + acceptReviewItems(ids: readonly string[]): boolean; + rejectReviewItems(ids: readonly string[]): boolean; + cancelActiveGeneration(): void; + openCommandMenu(): void; + closeCommandMenu(): void; + setSuggestMode(enabled: boolean): void; + setStreamingReviewPreview(input: AIStreamingReviewPreviewInput): void; + clearStreamingReviewPreview(sessionId?: string): void; + showEphemeralSuggestion(suggestion: EphemeralSuggestion): void; + dismissEphemeralSuggestion(): void; + acceptEphemeralSuggestion(): void; + getSuggestions(): readonly PersistentSuggestion[]; + acceptSuggestion(id: string): boolean; + rejectSuggestion(id: string): boolean; + acceptAllSuggestions(): void; + rejectAllSuggestions(): void; +} + +export interface AgenticLoopOptions { + model: ModelAdapter; + editor: Editor; + toolRuntime: ToolRuntime; + prompt: string; + blockId: string; + generationId?: string; + zoneId?: string; + maxSteps?: number; + signal?: AbortSignal; + requestMode?: string; + operation?: AIRequestedOperation | null; + sessionId?: string; + turnId?: string; + onStatusChange?: (status: AIAwarenessState["status"]) => void; + onStep?: (step: AgenticStep) => void; + onTextDelta?: (delta: string) => void; + onCompleteText?: (text: string) => void; + onToolCall?: (event: { + toolCallId: string; + toolName: string; + input: unknown; + }) => void; + onToolOutput?: (event: { + toolCallId: string; + toolName: string; + part: unknown; + output: unknown; + }) => void; + onToolResult?: (event: { + toolCallId: string; + toolName: string; + output: unknown; + state: "complete" | "error"; + }) => void; + onStructuredData?: (event: { + data: unknown; + final: boolean; + }) => void; + onMessagesChange?: (messages: ModelMessage[]) => void; + onStreamingStart?: (zoneId: string, blockId: string) => void; + onStreamingEnd?: (status: "complete" | "cancelled" | "error") => void; + workingSet?: AIWorkingSetEnvelope | null; + validateWorkingSet?: ( + workingSet: AIWorkingSetEnvelope | null, + ) => { valid: boolean; canRefresh: boolean; reason?: string }; + refreshWorkingSet?: () => Promise; + onDebug?: (debug: GenerationDebugState) => void; +} + +export interface AIWorkingSetEnvelope { + documentVersion: number; + viewMode: AIWorkingSetViewMode; + source: "cursor-context" | "document-summary" | "selection"; + context: unknown; + routeConfidence?: number; + trackedBlockIds: string[]; + blockRevisions: Record; + selectionSignature: string | null; +} + +export interface AIWorkingSetRetrievedSpan { + id: string; + blockIds: string[]; + range: { + startBlockId: string; + endBlockId: string; + }; + blockTypes: string[]; + headingPath: string[]; + preview: string; + markdown: string; + score: number; + rationale: string; + neighbors: { + beforeBlockId: string | null; + afterBlockId: string | null; + }; +} + +export interface GenerationDebugState { + messageAssemblyLatencyMs: number; + firstToolStartMs: number | null; + firstToolResultMs: number | null; + firstVisibleTextMs: number | null; + toolExecutionMs: number; + qualitySignals: Partial>; + routeConfidence?: number; + structured?: StructuredGenerationDebugState; + fastApply?: FastApplyDebugState; +} + +export interface StructuredGenerationDebugState { + plannerMode?: AIPlannerMode; + executionMode?: AIExecutionMode; + targetKind?: AITargetKind; + validationIssueCount?: number; +} + +export interface FastApplyDebugState { + attempted: boolean; + succeeded: boolean; + executionPath?: + | "native-fast-apply" + | "scoped-replacement" + | "plain-markdown"; + contextChars?: number; + diffChars?: number; + confidence?: number; + fallbackReason?: string; + verificationFailureReason?: string; + untouchedBlockMutationCount?: number; + alignment?: FlowPatchAlignmentMetrics; + fallback?: FastApplyFallbackMetrics; +} + +export interface FastApplyFallbackMetrics { + kind: "scoped-replacement" | "plain-markdown"; + opsCount: number; + insertedBlockCount: number; + deletedBlockCount: number; + targetBlockCount?: number; +} + +export type AIMutationReceiptStatus = + | "applied" + | "staged_review" + | "staged_suggestions" + | "noop" + | "invalid" + | "error"; + +export interface AIMutationReceiptEvidence { + commitId: string; + opsCount: number; + affectedBlockIds: string[]; + createdBlockIds: string[]; + adapterId: AIBlockAdapterId; + blockClass: AIBlockClass; + transportKind: AITransportKind; +} + +export interface AIMutationReceipt { + id: string; + status: AIMutationReceiptStatus; + evidence: AIMutationReceiptEvidence; + issues: string[]; +} diff --git a/packages/extensions/ai/src/types.ts b/packages/extensions/ai/src/types.ts index 7bcb264..60ed494 100644 --- a/packages/extensions/ai/src/types.ts +++ b/packages/extensions/ai/src/types.ts @@ -1,705 +1,71 @@ -import type { - Editor, - InlineCompletionController as CoreInlineCompletionController, - InlineCompletionState as CoreInlineCompletionState, - ModelAdapter, - ModelMessage, - ModelOperationScopedRangeTarget, - ModelOperationSelectionTarget, - ModelRequestedOperation, - SelectionState, - TextSelection, - ToolRuntime, -} from "@pen/types"; -import type { - AIApplyStrategy, - AIMutationMode, - AIRouteLane, - AIContentFormat, - AIBlockAdapterId, - AIBlockClass, - AIExecutionMode, - AIPlannerMode, - AIQualityMetricId, - AITargetKind, - AITransportKind, - AIWorkingSetViewMode, -} from "./runtime/contracts"; -import type { DocumentMutationPlan } from "./runtime/planTypes"; -import type { FlowPatchAlignmentMetrics } from "./runtime/planExecutor"; -import type { - StructuralReviewItem, - StructuredPreviewTargetState, -} from "./runtime/reviewArtifacts"; -import type { StructuredIntent } from "./runtime/structuredIntent"; - -export interface AIExtensionConfig { - model?: ModelAdapter; - suggestMode?: boolean; - commands?: AICommandBinding[]; - maxAgenticSteps?: number; - author?: string; - contentFormat?: AIContentFormatOptions; -} - -export interface AIContentFormatOptions { - blockGeneration?: AIContentFormat; - selectionRewrite?: AIContentFormat; -} - -export type ResolvedEditTarget = - | ModelOperationSelectionTarget - | ModelOperationScopedRangeTarget; - -export interface ResolvedEditProposal { - promptIntent: string; - target: ResolvedEditTarget; -} - -export type AIStatus = - | "idle" - | "reading" - | "thinking" - | "writing" - | "tool-calling"; - -export type AISurface = "inline-edit" | "bottom-chat"; - -export type AISessionStatus = - | "idle" - | "streaming" - | "paused" - | "complete" - | "cancelled" - | "error"; - -export type AISessionTarget = - | { - kind: "selection"; - selection: TextSelection; - blockId: string | null; - } - | { - kind: "block"; - blockId: string; - } - | { - kind: "document"; - }; - -export interface AISessionPrompt { - id: string; - prompt: string; - createdAt: number; - generationId?: string; - operation?: AIRequestedOperation; -} - -export interface AISessionSelectionSnapshot { - anchor: { blockId: string; offset: number }; - focus: { blockId: string; offset: number }; - blockRange: string[]; - isMultiBlock: boolean; -} - -export interface AIContextualPromptRect { - top: number; - left: number; - width: number; - height: number; -} - -export type AIContextualPromptAnchorKind = "text-range" | "block" | "document"; - -export type AIContextualPromptAnchorStatus = "valid" | "shifted" | "invalid"; - -export interface AIContextualPromptAnchor { - kind: AIContextualPromptAnchorKind; - selectionSnapshot?: AISessionSelectionSnapshot; - focusBlockId: string | null; - status: AIContextualPromptAnchorStatus; - lastResolvedRect: AIContextualPromptRect | null; -} - -export interface AIContextualPromptComposerState { - draftPrompt: string; - isOpen: boolean; - isSubmitting: boolean; - canSubmitFollowUp: boolean; - openReason?: "user" | "history"; -} - -export interface AIContextualPromptState { - anchor: AIContextualPromptAnchor; - composer: AIContextualPromptComposerState; -} - -export type AISessionTurnStatus = - | "streaming" - | "review" - | "accepted" - | "rejected" - | "complete" - | "cancelled" - | "error"; - -export interface AISessionTurn { - id: string; - prompt: string; - createdAt: number; - undoGroupId?: string; - generationId?: string; - target: Exclude; - status: AISessionTurnStatus; - suggestionIds: string[]; - reviewItemIds: string[]; - generatedBlockIds: string[]; - operation?: AIRequestedOperation; - structuredPreview?: GenerationStructuredPreviewState | null; - anchor?: AISessionAnchor; - selection?: AISessionSelectionSnapshot; -} - -export interface AISessionMetrics { - firstTokenMs?: number; - totalMs?: number; - toolMs?: number; - streamEventCount: number; - patchCount: number; - fastApply: AISessionFastApplyMetrics; -} - -export interface AISessionFastApplyMetrics { - attemptCount: number; - nativeFastApplyCount: number; - scopedReplacementCount: number; - plainMarkdownCount: number; - failedCount: number; -} - -export interface AISessionAnchor { - blockId?: string; - from?: number; - to?: number; -} - -export interface AISession { - id: string; - surface: AISurface; - status: AISessionStatus; - target: AISessionTarget; - operation?: AIRequestedOperation | null; - contextualPrompt?: AIContextualPromptState; - turns: AISessionTurn[]; - activeTurnId?: string; - promptHistory: AISessionPrompt[]; - generationIds: string[]; - pendingSuggestionIds: string[]; - pendingReviewItemIds: string[]; - createdAt: number; - updatedAt: number; - metrics: AISessionMetrics; - anchor?: AISessionAnchor; -} - -export interface AIInlineHistorySnapshot { - id: string; - sessionId: string | null; - sessions: readonly AISession[]; - activeSessionId: string | null; - documentVersion: number; - kind: "document-coupled" | "ui-local"; -} - -export interface AgenticStep { - index: number; - type: "text" | "tool-call" | "tool-result"; - toolName?: string; - toolCallId?: string; - input?: unknown; - output?: unknown; - status: "pending" | "running" | "complete" | "error"; -} - -export type AIStreamEventType = - | "generation-start" - | "status" - | "text-delta" - | "operation" - | "app-partial" - | "tool-call" - | "tool-output" - | "tool-result" - | "structured-preview" - | "generation-finish"; - -export interface AIStreamEventBase { - type: AIStreamEventType; - generationId: string; - sessionId?: string; - zoneId: string; - blockId: string; - timestamp: number; -} - -export type AIStreamEvent = - | (AIStreamEventBase & { - type: "generation-start"; - prompt: string; - target: GenerationState["target"]; - }) - | (AIStreamEventBase & { - type: "status"; - status: AIStatus; - }) - | (AIStreamEventBase & { - type: "text-delta"; - delta: string; - text: string; - }) - | (AIStreamEventBase & { - type: "app-partial"; - data: unknown; - final: boolean; - }) - | (AIStreamEventBase & { - type: "tool-call"; - toolCallId: string; - toolName: string; - input: unknown; - }) - | (AIStreamEventBase & { - type: "tool-output"; - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - }) - | (AIStreamEventBase & { - type: "tool-result"; - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - }) - | (AIStreamEventBase & { - type: "structured-preview"; - preview: GenerationStructuredPreviewState; - patches: readonly StructuredPreviewPatchOperation[]; - }) - | (AIStreamEventBase & { - type: "operation"; - operation: AIRequestedOperation; - phase: "preview" | "final" | "conflict"; - text?: string; - reason?: string; - }) - | (AIStreamEventBase & { - type: "generation-finish"; - status: GenerationState["status"]; - text: string; - }); - -export interface StructuredPreviewPatchOperation { - op: "add" | "remove" | "replace"; - path: string; - value?: unknown; -} - -export interface GenerationStructuredPreviewState { - planState: "drafted" | "validated"; - plan: DocumentMutationPlan; - reviewItems: StructuralReviewItem[]; - targets: StructuredPreviewTargetState[]; -} - -export interface GenerationState { - id: string; - zoneId: string; - blockId: string; - target: "selection" | "block"; - sessionId?: string; - turnId?: string; - surface?: AISurface; - prompt: string; - operation?: AIRequestedOperation | null; - status: "streaming" | "complete" | "cancelled" | "error"; - tokenCount: number; - steps: AgenticStep[]; - undoGroupId: string; - text: string; - commandId?: string; - suggestionIds?: string[]; - route?: AIRouteLane; - mutationMode?: AIMutationMode; - contentFormat?: AIContentFormat; - applyStrategy?: AIApplyStrategy; - planState?: GenerationPlanState; - plan?: DocumentMutationPlan | null; - structuredIntent?: StructuredIntent | null; - reviewItems?: StructuralReviewItem[]; - structuredPreview?: GenerationStructuredPreviewState | null; - targetKind?: GenerationTargetKind; - blockClass?: AIBlockClass; - adapterId?: AIBlockAdapterId; - transportKind?: AITransportKind; - mutationReceipt?: AIMutationReceipt | null; - debug?: GenerationDebugState; -} - -export type GenerationPlanState = - | "none" - | "drafted" - | "validated" - | "rejected"; - -export type GenerationTargetKind = AITargetKind; - -export interface EphemeralSuggestion { - id: string; - blockId: string; - offset: number; - text: string; - type: "inline" | "block"; - blockType?: string; - props?: Record; -} - -export type AIInlineCompletionState = CoreInlineCompletionState; -export type AIInlineCompletionController = CoreInlineCompletionController; - -interface PersistentSuggestionBase { - id: string; - author: string; - authorType: "user" | "ai"; - createdAt: number; - model?: string; - sessionId?: string; - blockId: string; -} - -export interface PersistentTextSuggestion extends PersistentSuggestionBase { - kind: "text"; - action: "insert" | "delete"; - offset: number; - length: number; -} - -export interface PersistentBlockSuggestion extends PersistentSuggestionBase { - kind: "block"; - action: "insert-block" | "delete-block" | "move-block" | "convert-block"; - previousState?: { - type?: string; - position?: import("@pen/types").Position; - props?: Record; - }; -} - -export type PersistentSuggestion = - | PersistentTextSuggestion - | PersistentBlockSuggestion; - -export interface BlockSuggestionMeta { - id: string; - action: "insert-block" | "delete-block" | "move-block" | "convert-block"; - author: string; - authorType: "user" | "ai"; - createdAt: number; - model?: string; - sessionId?: string; - previousState?: { - type?: string; - position?: import("@pen/types").Position; - props?: Record; - }; -} - -export interface AIAwarenessState { - status: AIStatus; - activeBlockId: string | null; - activeTool?: { name: string; toolCallId: string }; - model: string; - generationZoneId?: string; -} - -export interface AICommandContext { - editor: Editor; - selection: SelectionState; - selectedText: string; - blockType: string | null; - blockId: string | null; -} - -export type AICommandGuard = (ctx: AICommandContext) => boolean; - -export interface AICommandBinding { - id: string; - label: string; - description?: string; - icon?: string; - group?: string; - prompt: string | ((ctx: AICommandContext) => string); - guard?: AICommandGuard; - shortcut?: string; - target?: "selection" | "block"; -} - -export interface AIControllerState { - status: AIStatus; - activeGeneration: GenerationState | null; - sessions: readonly AISession[]; - activeSessionId?: string | null; - suggestMode: boolean; - ephemeralSuggestion: EphemeralSuggestion | null; - commandMenuOpen: boolean; - lastRoute?: AIRouteLane; -} - -export type AIPromptTarget = "auto" | "selection" | "block" | "document"; -export type AISessionResolution = "accept" | "reject"; -export type AIInlineHistoryDirection = "undo" | "redo"; - -export interface AIInlineHistoryController { - canUndoInlineHistory(): boolean; - canRedoInlineHistory(): boolean; - canHandleShortcut(direction: AIInlineHistoryDirection): boolean; - handleShortcut(direction: AIInlineHistoryDirection): boolean; - undoInlineHistory(): boolean; - redoInlineHistory(): boolean; -} - -export interface AIReviewController { - getSuggestions(): readonly PersistentSuggestion[]; - acceptSuggestion(id: string): boolean; - rejectSuggestion(id: string): boolean; - acceptAllSuggestions(): void; - rejectAllSuggestions(): void; -} - -export interface AICommandExecutionOptions { - blockId?: string | null; - maxSteps?: number; - target?: AIPromptTarget; - operation?: AIRequestedOperation | null; -} - -export type AIRequestedOperation = ModelRequestedOperation; - -export interface AIController { - getState(): AIControllerState; - subscribe(listener: () => void): () => void; - getSessions(): readonly AISession[]; - getActiveSession(): AISession | null; - subscribeSessions(listener: () => void): () => void; - getStreamEvents(): readonly AIStreamEvent[]; - subscribeStreamEvents(listener: () => void): () => void; - getCommands(): readonly AICommandBinding[]; - getCommandContext(): AICommandContext; - startSession(input: { - surface: AISurface; - target?: "auto" | "selection" | "block" | "document"; - }): AISession; - openContextualPrompt(input?: { - surface?: Extract; - target?: "auto" | "selection" | "block" | "document"; - }): AISession | null; - updateContextualPromptDraft(sessionId: string, draftPrompt: string): void; - setContextualPromptAnchorRect( - sessionId: string, - rect: AIContextualPromptRect | null, - ): void; - runSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): Promise; - canReuseSessionPrompt( - sessionId: string, - prompt: string, - options?: AICommandExecutionOptions, - ): boolean; - resolveSessionTurn( - sessionId: string, - turnId: string, - resolution: AISessionResolution, - ): boolean; - acceptSessionTurn(sessionId: string, turnId: string): boolean; - rejectSessionTurn(sessionId: string, turnId: string): boolean; - resolveSession(sessionId: string, resolution: AISessionResolution): boolean; - acceptSession(sessionId: string): boolean; - rejectSession(sessionId: string): boolean; - cancelSession(sessionId: string): void; - suspendInlineSession(sessionId: string): void; - resumeInlineSession(sessionId: string): void; - canUndoInlineHistory(): boolean; - canRedoInlineHistory(): boolean; - undoInlineHistory(): boolean; - redoInlineHistory(): boolean; - runCommand(commandId: string, options?: AICommandExecutionOptions): Promise; - runPrompt(prompt: string, options?: AICommandExecutionOptions): Promise; - retryActiveGeneration(): Promise; - acceptActiveGeneration(): boolean; - rejectActiveGeneration(): boolean; - acceptReviewItem(id: string): boolean; - rejectReviewItem(id: string): boolean; - acceptReviewItems(ids: readonly string[]): boolean; - rejectReviewItems(ids: readonly string[]): boolean; - cancelActiveGeneration(): void; - openCommandMenu(): void; - closeCommandMenu(): void; - setSuggestMode(enabled: boolean): void; - showEphemeralSuggestion(suggestion: EphemeralSuggestion): void; - dismissEphemeralSuggestion(): void; - acceptEphemeralSuggestion(): void; - getSuggestions(): readonly PersistentSuggestion[]; - acceptSuggestion(id: string): boolean; - rejectSuggestion(id: string): boolean; - acceptAllSuggestions(): void; - rejectAllSuggestions(): void; -} - -export interface AgenticLoopOptions { - model: ModelAdapter; - editor: Editor; - toolRuntime: ToolRuntime; - prompt: string; - blockId: string; - generationId?: string; - zoneId?: string; - maxSteps?: number; - signal?: AbortSignal; - requestMode?: string; - onStatusChange?: (status: AIAwarenessState["status"]) => void; - onStep?: (step: AgenticStep) => void; - onTextDelta?: (delta: string) => void; - onCompleteText?: (text: string) => void; - onToolCall?: (event: { - toolCallId: string; - toolName: string; - input: unknown; - }) => void; - onToolOutput?: (event: { - toolCallId: string; - toolName: string; - part: unknown; - output: unknown; - }) => void; - onToolResult?: (event: { - toolCallId: string; - toolName: string; - output: unknown; - state: "complete" | "error"; - }) => void; - onStructuredData?: (event: { - data: unknown; - final: boolean; - }) => void; - onMessagesChange?: (messages: ModelMessage[]) => void; - onStreamingStart?: (zoneId: string, blockId: string) => void; - onStreamingEnd?: (status: "complete" | "cancelled" | "error") => void; - workingSet?: AIWorkingSetEnvelope | null; - validateWorkingSet?: ( - workingSet: AIWorkingSetEnvelope | null, - ) => { valid: boolean; canRefresh: boolean; reason?: string }; - refreshWorkingSet?: () => Promise; - onDebug?: (debug: GenerationDebugState) => void; -} - -export interface AIWorkingSetEnvelope { - documentVersion: number; - viewMode: AIWorkingSetViewMode; - source: "cursor-context" | "document-summary" | "selection"; - context: unknown; - routeConfidence?: number; - trackedBlockIds: string[]; - blockRevisions: Record; - selectionSignature: string | null; -} - -export interface AIWorkingSetRetrievedSpan { - id: string; - blockIds: string[]; - range: { - startBlockId: string; - endBlockId: string; - }; - blockTypes: string[]; - headingPath: string[]; - preview: string; - markdown: string; - score: number; - rationale: string; - neighbors: { - beforeBlockId: string | null; - afterBlockId: string | null; - }; -} - -export interface GenerationDebugState { - messageAssemblyLatencyMs: number; - firstToolStartMs: number | null; - firstToolResultMs: number | null; - firstVisibleTextMs: number | null; - toolExecutionMs: number; - qualitySignals: Partial>; - routeConfidence?: number; - structured?: StructuredGenerationDebugState; - fastApply?: FastApplyDebugState; -} - -export interface StructuredGenerationDebugState { - plannerMode?: AIPlannerMode; - executionMode?: AIExecutionMode; - targetKind?: AITargetKind; - validationIssueCount?: number; -} - -export interface FastApplyDebugState { - attempted: boolean; - succeeded: boolean; - executionPath?: - | "native-fast-apply" - | "scoped-replacement" - | "plain-markdown"; - contextChars?: number; - diffChars?: number; - confidence?: number; - fallbackReason?: string; - verificationFailureReason?: string; - untouchedBlockMutationCount?: number; - alignment?: FlowPatchAlignmentMetrics; - fallback?: FastApplyFallbackMetrics; -} - -export interface FastApplyFallbackMetrics { - kind: "scoped-replacement" | "plain-markdown"; - opsCount: number; - insertedBlockCount: number; - deletedBlockCount: number; - targetBlockCount?: number; -} - -export type AIMutationReceiptStatus = - | "applied" - | "staged_review" - | "staged_suggestions" - | "noop" - | "invalid" - | "error"; - -export interface AIMutationReceiptEvidence { - commitId: string; - opsCount: number; - affectedBlockIds: string[]; - createdBlockIds: string[]; - adapterId: AIBlockAdapterId; - blockClass: AIBlockClass; - transportKind: AITransportKind; -} - -export interface AIMutationReceipt { - id: string; - status: AIMutationReceiptStatus; - evidence: AIMutationReceiptEvidence; - issues: string[]; -} +export type { + AIExtensionConfig, + AIContentFormatOptions, + AISuggestionPresentation, + ResolvedEditTarget, + ResolvedEditProposal, + AIStatus, + AISurface, + AISessionStatus, + AISessionTarget, + AISessionPrompt, + AISessionSelectionSnapshot, + AIContextualPromptRect, + AIContextualPromptAnchorKind, + AIContextualPromptAnchorStatus, + AIContextualPromptAnchor, + AIContextualPromptComposerState, + AIContextualPromptState, + AISessionTurnStatus, + AISessionTurn, + AISessionMetrics, + AISessionFastApplyMetrics, + AISessionAnchor, + AIStreamingReviewPreviewTarget, + AIStreamingReviewPreviewInput, + AIStreamingReviewPreview, + AISession, + AIInlineHistorySnapshot, + AIExternalInlineTurnResult, + AgenticStep, + AIStreamEventType, + AIStreamEventBase, + AIStreamEvent, + StructuredPreviewPatchOperation, + GenerationStructuredPreviewState, + GenerationState, + GenerationPlanState, + GenerationTargetKind, + EphemeralSuggestion, + AIInlineCompletionState, + AIInlineCompletionController, + PersistentTextSuggestion, +} from "./typeParts/typesPart1"; +export type { + PersistentBlockSuggestion, + PersistentSuggestion, + BlockSuggestionMeta, + AIAwarenessState, + AICommandContext, + AICommandGuard, + AICommandBinding, + AIControllerState, + AIPromptTarget, + AISessionResolution, + AIInlineHistoryDirection, + AIInlineHistoryController, + AIReviewController, + AICommandExecutionOptions, + AIRequestedOperation, + AIController, + AgenticLoopOptions, + AIWorkingSetEnvelope, + AIWorkingSetRetrievedSpan, + GenerationDebugState, + StructuredGenerationDebugState, + FastApplyDebugState, + FastApplyFallbackMetrics, + AIMutationReceiptStatus, + AIMutationReceiptEvidence, + AIMutationReceipt, +} from "./typeParts/typesPart2"; diff --git a/packages/extensions/database/src/__tests__/engine.part2.test.ts b/packages/extensions/database/src/__tests__/engine.part2.test.ts new file mode 100644 index 0000000..cf6f0a0 --- /dev/null +++ b/packages/extensions/database/src/__tests__/engine.part2.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it, vi } from "vitest"; +import { DatabaseEngine } from "../engine"; +import type { Editor } from "@pen/types"; +import { + isContentEditableColumnType, + DEFAULT_COLUMNS, + type DatabaseRow, + type DatabaseDataProvider, +} from "../types"; + +type DatabaseEngineTestBlock = { + id: string; + type: string; + props: { title: string; dataSource: string }; + tableRowCount(): number; + tableColumnCount(): number; + tableColumns(): Array<{ + id: string; + title: string; + type: string; + width: number; + }>; + tableCell(r: number, c: number): { + id: string; + textContent(): string; + }; +}; + +type DatabaseEngineTestEditor = { + getBlock(id: string): DatabaseEngineTestBlock | null; + selection: null; + apply(): void; + selectCell(): void; + selectCellRange(): void; +}; + +function createMockEditor(rowCount = 3, colCount = 3) { + const columns = [ + { id: "col-0", title: "Name", type: "text", width: 150 }, + { id: "col-1", title: "Age", type: "number", width: 100 }, + { id: "col-2", title: "Done", type: "checkbox", width: 80 }, + ]; + + const cells: Record = { + "0-0": "Alice", + "0-1": "30", + "0-2": "true", + "1-0": "Bob", + "1-1": "25", + "1-2": "false", + "2-0": "Charlie", + "2-1": "35", + "2-2": "true", + }; + + const block = { + id: "block-1", + type: "database", + props: { title: "Test DB", dataSource: "local" }, + tableRowCount: () => rowCount, + tableColumnCount: () => colCount, + tableColumns: () => columns.slice(0, colCount), + tableCell: (r: number, c: number) => { + const key = `${r}-${c}`; + const text = cells[key] ?? ""; + return { + id: key, + textContent: () => text, + }; + }, + } satisfies DatabaseEngineTestBlock; + + const editor = { + getBlock: (id: string) => (id === "block-1" ? block : null), + selection: null, + apply: () => { }, + selectCell: () => { }, + selectCellRange: () => { }, + } satisfies DatabaseEngineTestEditor; + + return { + editor: editor as unknown as Editor, + block, + }; +} + +describe("DatabaseEngine filtering", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("filters text by contains", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { title: "Hello World" } }, + { id: "b", crdtRowIndex: 1, cells: { title: "Hello" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ columnId: "title", operator: "contains", value: "world" }], + }, + [{ id: "title", title: "Title", type: "text", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a"]); + }); + + it("filters checkbox by checked state", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { done: "true" } }, + { id: "b", crdtRowIndex: 1, cells: { done: "false" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ columnId: "done", operator: "is_checked", value: null }], + }, + [{ id: "done", title: "Done", type: "checkbox", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a"]); + }); + + it("filters select values by stored option ids", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ columnId: "status", operator: "is", value: "todo" }], + }, + [ + { + id: "status", + title: "Status", + type: "select", + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ], + ); + expect(filtered.map((row) => row.id)).toEqual(["a"]); + }); + + it("filters dates inclusively by between", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-10T09:00:00.000Z" } }, + { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-15T09:00:00.000Z" } }, + { id: "c", crdtRowIndex: 2, cells: { due: "2024-03-20T09:00:00.000Z" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ + columnId: "due", + operator: "is_between", + value: ["2024-03-10", "2024-03-15"], + }], + }, + [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); + }); + + it("filters dates by relative presets", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-03-15T12:00:00.000Z")); + try { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, + { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-11T09:00:00.000Z" } }, + { id: "c", crdtRowIndex: 2, cells: { due: "2024-02-28T09:00:00.000Z" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ + columnId: "due", + operator: "is_relative", + value: "last_7_days", + }], + }, + [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], + ); + expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); + } finally { + vi.useRealTimers(); + } + }); + + it("does not match dates for unknown relative presets", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, + ]; + const filtered = engine.filterRows( + rows, + { + operator: "and", + conditions: [{ + columnId: "due", + operator: "is_relative", + value: "not_a_real_preset", + }], + }, + [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], + ); + expect(filtered).toEqual([]); + }); +}); +describe("DatabaseEngine search", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("searches across visible columns using formatted display values", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { status: "todo", hidden: "Needle" } }, + { id: "b", crdtRowIndex: 1, cells: { status: "done", hidden: "" } }, + ]; + const columns = [ + { + id: "status", + title: "Status", + type: "select" as const, + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ]; + + expect(engine.searchRows(rows, "todo", columns).map((row) => row.id)).toEqual(["a"]); + expect(engine.searchRows(rows, "needle", columns)).toEqual([]); + }); +}); +describe("DatabaseEngine faceting", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("builds select facet buckets from stored option ids", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, + { id: "c", crdtRowIndex: 2, cells: { status: "todo" } }, + ]; + const columns = [ + { + id: "status", + title: "Status", + type: "select" as const, + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ]; + + expect(engine.facetColumnValues(rows, "status", columns)).toEqual([ + { value: "done", label: "Done", count: 1 }, + { value: "todo", label: "Todo", count: 2 }, + ]); + }); + + it("builds multiSelect facet buckets per selected option", () => { + const rows: DatabaseRow[] = [ + { id: "a", crdtRowIndex: 0, cells: { tags: '["bug","feat"]' } }, + { id: "b", crdtRowIndex: 1, cells: { tags: '["bug"]' } }, + ]; + const columns = [ + { + id: "tags", + title: "Tags", + type: "multiSelect" as const, + columnIndex: 0, + options: [ + { id: "bug", value: "Bug" }, + { id: "feat", value: "Feature" }, + ], + }, + ]; + + expect(engine.facetColumnValues(rows, "tags", columns)).toEqual([ + { value: "bug", label: "Bug", count: 2 }, + { value: "feat", label: "Feature", count: 1 }, + ]); + }); +}); +describe("DatabaseEngine view model helpers", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + it("includes groupBy in remote queries", () => { + expect( + engine.createQuery({ + view: { + id: "view-1", + type: "table", + groupBy: "status", + sort: [{ columnId: "name", direction: "asc" }], + pageIndex: 2, + pageSize: 25, + }, + }), + ).toEqual({ + groupBy: "status", + sort: [{ columnId: "name", direction: "asc" }], + filter: undefined, + pageIndex: 2, + pageSize: 25, + }); + }); + + it("splits pinned rows out of the paginated middle rows", () => { + const rows: DatabaseRow[] = [ + { id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }, + { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, + { id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }, + { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, + ]; + + expect( + engine.splitPinnedRows(rows, { + top: ["row-c"], + bottom: ["row-a"], + }), + ).toEqual({ + top: [{ id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }], + rows: [ + { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, + { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, + ], + bottom: [{ id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }], + }); + }); + + it("groups rows by formatted display label", () => { + const rows: DatabaseRow[] = [ + { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }, + { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, + { id: "row-d", crdtRowIndex: 3, cells: { status: "" } }, + ]; + const columns = [ + { + id: "status", + title: "Status", + type: "select" as const, + columnIndex: 0, + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ]; + + expect(engine.groupRows(rows, "status", columns)).toEqual([ + { + key: "status:Todo", + label: "Todo", + rows: [ + { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, + { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, + ], + }, + { + key: "status:Done", + label: "Done", + rows: [{ id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }], + }, + { + key: "status:(empty)", + label: "(empty)", + rows: [{ id: "row-d", crdtRowIndex: 3, cells: { status: "" } }], + }, + ]); + }); +}); +describe("isContentEditableColumnType", () => { + it("returns true for text-like types", () => { + expect(isContentEditableColumnType("text")).toBe(true); + expect(isContentEditableColumnType("number")).toBe(true); + expect(isContentEditableColumnType("url")).toBe(true); + expect(isContentEditableColumnType("email")).toBe(true); + }); + + it("returns false for widget types", () => { + expect(isContentEditableColumnType("checkbox")).toBe(false); + expect(isContentEditableColumnType("select")).toBe(false); + expect(isContentEditableColumnType("multiSelect")).toBe(false); + expect(isContentEditableColumnType("date")).toBe(false); + expect(isContentEditableColumnType("relation")).toBe(false); + expect(isContentEditableColumnType("formula")).toBe(false); + }); + + it("returns true for undefined/null", () => { + expect(isContentEditableColumnType(undefined)).toBe(true); + expect(isContentEditableColumnType("")).toBe(true); + }); +}); +describe("DEFAULT_COLUMNS", () => { + it("has 3 columns with unique IDs", () => { + expect(DEFAULT_COLUMNS).toHaveLength(3); + const ids = DEFAULT_COLUMNS.map((c) => c.id); + expect(new Set(ids).size).toBe(3); + }); + + it("includes Name (text), Tags (select), Done (checkbox)", () => { + expect(DEFAULT_COLUMNS[0].title).toBe("Name"); + expect(DEFAULT_COLUMNS[0].type).toBe("text"); + expect(DEFAULT_COLUMNS[1].title).toBe("Tags"); + expect(DEFAULT_COLUMNS[1].type).toBe("select"); + expect(DEFAULT_COLUMNS[2].title).toBe("Done"); + expect(DEFAULT_COLUMNS[2].type).toBe("checkbox"); + }); +}); +describe("DatabaseEngine data provider", () => { + it("stores and retrieves data provider", () => { + const { editor } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + expect(engine.dataProvider).toBeNull(); + + const provider: DatabaseDataProvider = { + fetch: async () => ({ rows: [], totalRows: 0, pageIndex: 0, pageSize: 50 }), + }; + engine.setDataProvider(provider); + expect(engine.dataProvider).toBe(provider); + }); + + it("detects remote mode from block props", () => { + const { editor, block } = createMockEditor(); + const engine = new DatabaseEngine(editor, "block-1"); + + expect(engine.isRemote).toBe(false); + block.props.dataSource = "remote"; + expect(engine.isRemote).toBe(true); + block.props.dataSource = "hybrid"; + expect(engine.isRemote).toBe(true); + }); +}); diff --git a/packages/extensions/database/src/__tests__/engine.test.ts b/packages/extensions/database/src/__tests__/engine.test.ts index 57e705e..f6f376d 100644 --- a/packages/extensions/database/src/__tests__/engine.test.ts +++ b/packages/extensions/database/src/__tests__/engine.test.ts @@ -128,7 +128,6 @@ describe("DatabaseEngine", () => { expect(engine.getRowId(row)).toBe("row-42"); }); }); - describe("DatabaseEngine value parsing", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -166,7 +165,6 @@ describe("DatabaseEngine value parsing", () => { expect(engine.parseCellValue("2 + 2", "formula")).toBe("2 + 2"); }); }); - describe("DatabaseEngine value serialization", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -191,7 +189,6 @@ describe("DatabaseEngine value serialization", () => { expect(engine.serializeCellValue(null, "multiSelect")).toBe(""); }); }); - describe("DatabaseEngine validation", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -217,7 +214,6 @@ describe("DatabaseEngine validation", () => { expect(engine.validateCellValue("not a url", "url")).toBe("Invalid URL"); }); }); - describe("DatabaseEngine cell display formatting", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -258,7 +254,6 @@ describe("DatabaseEngine cell display formatting", () => { expect(engine.formatCellDisplay("", "date")).toBe(""); }); }); - describe("DatabaseEngine type coercion", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -324,7 +319,6 @@ describe("DatabaseEngine type coercion", () => { expect(engine.coerceValue("", "text", "number")).toBe(""); }); }); - describe("DatabaseEngine sorting", () => { const { editor } = createMockEditor(); const engine = new DatabaseEngine(editor, "block-1"); @@ -360,371 +354,3 @@ describe("DatabaseEngine sorting", () => { expect(sorted.map((row) => row.id)).toEqual(["a", "b"]); }); }); - -describe("DatabaseEngine filtering", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("filters text by contains", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { title: "Hello World" } }, - { id: "b", crdtRowIndex: 1, cells: { title: "Hello" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ columnId: "title", operator: "contains", value: "world" }], - }, - [{ id: "title", title: "Title", type: "text", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a"]); - }); - - it("filters checkbox by checked state", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { done: "true" } }, - { id: "b", crdtRowIndex: 1, cells: { done: "false" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ columnId: "done", operator: "is_checked", value: null }], - }, - [{ id: "done", title: "Done", type: "checkbox", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a"]); - }); - - it("filters select values by stored option ids", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ columnId: "status", operator: "is", value: "todo" }], - }, - [ - { - id: "status", - title: "Status", - type: "select", - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ], - ); - expect(filtered.map((row) => row.id)).toEqual(["a"]); - }); - - it("filters dates inclusively by between", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-10T09:00:00.000Z" } }, - { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-15T09:00:00.000Z" } }, - { id: "c", crdtRowIndex: 2, cells: { due: "2024-03-20T09:00:00.000Z" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ - columnId: "due", - operator: "is_between", - value: ["2024-03-10", "2024-03-15"], - }], - }, - [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); - }); - - it("filters dates by relative presets", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2024-03-15T12:00:00.000Z")); - try { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, - { id: "b", crdtRowIndex: 1, cells: { due: "2024-03-11T09:00:00.000Z" } }, - { id: "c", crdtRowIndex: 2, cells: { due: "2024-02-28T09:00:00.000Z" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ - columnId: "due", - operator: "is_relative", - value: "last_7_days", - }], - }, - [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], - ); - expect(filtered.map((row) => row.id)).toEqual(["a", "b"]); - } finally { - vi.useRealTimers(); - } - }); - - it("does not match dates for unknown relative presets", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { due: "2024-03-15T09:00:00.000Z" } }, - ]; - const filtered = engine.filterRows( - rows, - { - operator: "and", - conditions: [{ - columnId: "due", - operator: "is_relative", - value: "not_a_real_preset", - }], - }, - [{ id: "due", title: "Due", type: "date", columnIndex: 0 }], - ); - expect(filtered).toEqual([]); - }); -}); - -describe("DatabaseEngine search", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("searches across visible columns using formatted display values", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { status: "todo", hidden: "Needle" } }, - { id: "b", crdtRowIndex: 1, cells: { status: "done", hidden: "" } }, - ]; - const columns = [ - { - id: "status", - title: "Status", - type: "select" as const, - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ]; - - expect(engine.searchRows(rows, "todo", columns).map((row) => row.id)).toEqual(["a"]); - expect(engine.searchRows(rows, "needle", columns)).toEqual([]); - }); -}); - -describe("DatabaseEngine faceting", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("builds select facet buckets from stored option ids", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "b", crdtRowIndex: 1, cells: { status: "done" } }, - { id: "c", crdtRowIndex: 2, cells: { status: "todo" } }, - ]; - const columns = [ - { - id: "status", - title: "Status", - type: "select" as const, - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ]; - - expect(engine.facetColumnValues(rows, "status", columns)).toEqual([ - { value: "done", label: "Done", count: 1 }, - { value: "todo", label: "Todo", count: 2 }, - ]); - }); - - it("builds multiSelect facet buckets per selected option", () => { - const rows: DatabaseRow[] = [ - { id: "a", crdtRowIndex: 0, cells: { tags: '["bug","feat"]' } }, - { id: "b", crdtRowIndex: 1, cells: { tags: '["bug"]' } }, - ]; - const columns = [ - { - id: "tags", - title: "Tags", - type: "multiSelect" as const, - columnIndex: 0, - options: [ - { id: "bug", value: "Bug" }, - { id: "feat", value: "Feature" }, - ], - }, - ]; - - expect(engine.facetColumnValues(rows, "tags", columns)).toEqual([ - { value: "bug", label: "Bug", count: 2 }, - { value: "feat", label: "Feature", count: 1 }, - ]); - }); -}); - -describe("DatabaseEngine view model helpers", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - it("includes groupBy in remote queries", () => { - expect( - engine.createQuery({ - view: { - id: "view-1", - type: "table", - groupBy: "status", - sort: [{ columnId: "name", direction: "asc" }], - pageIndex: 2, - pageSize: 25, - }, - }), - ).toEqual({ - groupBy: "status", - sort: [{ columnId: "name", direction: "asc" }], - filter: undefined, - pageIndex: 2, - pageSize: 25, - }); - }); - - it("splits pinned rows out of the paginated middle rows", () => { - const rows: DatabaseRow[] = [ - { id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }, - { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, - { id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }, - { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, - ]; - - expect( - engine.splitPinnedRows(rows, { - top: ["row-c"], - bottom: ["row-a"], - }), - ).toEqual({ - top: [{ id: "row-c", crdtRowIndex: 2, cells: { name: "C" } }], - rows: [ - { id: "row-b", crdtRowIndex: 1, cells: { name: "B" } }, - { id: "row-d", crdtRowIndex: 3, cells: { name: "D" } }, - ], - bottom: [{ id: "row-a", crdtRowIndex: 0, cells: { name: "A" } }], - }); - }); - - it("groups rows by formatted display label", () => { - const rows: DatabaseRow[] = [ - { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }, - { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, - { id: "row-d", crdtRowIndex: 3, cells: { status: "" } }, - ]; - const columns = [ - { - id: "status", - title: "Status", - type: "select" as const, - columnIndex: 0, - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ]; - - expect(engine.groupRows(rows, "status", columns)).toEqual([ - { - key: "status:Todo", - label: "Todo", - rows: [ - { id: "row-a", crdtRowIndex: 0, cells: { status: "todo" } }, - { id: "row-c", crdtRowIndex: 2, cells: { status: "todo" } }, - ], - }, - { - key: "status:Done", - label: "Done", - rows: [{ id: "row-b", crdtRowIndex: 1, cells: { status: "done" } }], - }, - { - key: "status:(empty)", - label: "(empty)", - rows: [{ id: "row-d", crdtRowIndex: 3, cells: { status: "" } }], - }, - ]); - }); -}); - -describe("isContentEditableColumnType", () => { - it("returns true for text-like types", () => { - expect(isContentEditableColumnType("text")).toBe(true); - expect(isContentEditableColumnType("number")).toBe(true); - expect(isContentEditableColumnType("url")).toBe(true); - expect(isContentEditableColumnType("email")).toBe(true); - }); - - it("returns false for widget types", () => { - expect(isContentEditableColumnType("checkbox")).toBe(false); - expect(isContentEditableColumnType("select")).toBe(false); - expect(isContentEditableColumnType("multiSelect")).toBe(false); - expect(isContentEditableColumnType("date")).toBe(false); - expect(isContentEditableColumnType("relation")).toBe(false); - expect(isContentEditableColumnType("formula")).toBe(false); - }); - - it("returns true for undefined/null", () => { - expect(isContentEditableColumnType(undefined)).toBe(true); - expect(isContentEditableColumnType("")).toBe(true); - }); -}); - -describe("DEFAULT_COLUMNS", () => { - it("has 3 columns with unique IDs", () => { - expect(DEFAULT_COLUMNS).toHaveLength(3); - const ids = DEFAULT_COLUMNS.map((c) => c.id); - expect(new Set(ids).size).toBe(3); - }); - - it("includes Name (text), Tags (select), Done (checkbox)", () => { - expect(DEFAULT_COLUMNS[0].title).toBe("Name"); - expect(DEFAULT_COLUMNS[0].type).toBe("text"); - expect(DEFAULT_COLUMNS[1].title).toBe("Tags"); - expect(DEFAULT_COLUMNS[1].type).toBe("select"); - expect(DEFAULT_COLUMNS[2].title).toBe("Done"); - expect(DEFAULT_COLUMNS[2].type).toBe("checkbox"); - }); -}); - -describe("DatabaseEngine data provider", () => { - it("stores and retrieves data provider", () => { - const { editor } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - expect(engine.dataProvider).toBeNull(); - - const provider: DatabaseDataProvider = { - fetch: async () => ({ rows: [], totalRows: 0, pageIndex: 0, pageSize: 50 }), - }; - engine.setDataProvider(provider); - expect(engine.dataProvider).toBe(provider); - }); - - it("detects remote mode from block props", () => { - const { editor, block } = createMockEditor(); - const engine = new DatabaseEngine(editor, "block-1"); - - expect(engine.isRemote).toBe(false); - block.props.dataSource = "remote"; - expect(engine.isRemote).toBe(true); - block.props.dataSource = "hybrid"; - expect(engine.isRemote).toBe(true); - }); -}); diff --git a/packages/extensions/database/src/__tests__/renderer.part10.test.tsx b/packages/extensions/database/src/__tests__/renderer.part10.test.tsx new file mode 100644 index 0000000..c55087d --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part10.test.tsx @@ -0,0 +1,401 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("renders list views as stacked row cards", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-list", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-add-view", + blockId: "db-list", + view: { + id: "view-list", + title: "List view", + type: "list", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: null, + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-list", + viewId: "view-list", + }, + { + type: "database-insert-row", + blockId: "db-list", + rowId: "row-a", + values: { name: "Alpha", tags: "Todo", status: "true" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const listView = container.querySelector( + `[data-block-id="db-list"] .pen-db-list-view`, + ) as HTMLDivElement | null; + expect(listView).not.toBeNull(); + expect(container.querySelector(`[data-block-id="db-list"] table[data-pen-table]`)).toBeNull(); + + const listRow = container.querySelector( + `[data-block-id="db-list"] .pen-db-list-row[data-row="0"]`, + ) as HTMLDivElement | null; + expect(listRow).not.toBeNull(); + expect(listRow?.textContent).toContain("Name"); + expect(listRow?.textContent).toContain("Tags"); + expect(listRow?.textContent).toContain("Done"); + expect(listRow?.textContent).toContain("Alpha"); + + await unmountDatabase(root, container, editor); + }); + + it("renders board views as grouped kanban lanes", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-board", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-board", + columnId: "tags", + patch: { + title: "Status", + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + }, + { + type: "database-add-view", + blockId: "db-board", + view: { + id: "view-board", + title: "Board view", + type: "board", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: "tags", + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-board", + viewId: "view-board", + }, + { + type: "database-insert-row", + blockId: "db-board", + rowId: "row-a", + values: { name: "Alpha", tags: "todo", status: "true" }, + }, + { + type: "database-insert-row", + blockId: "db-board", + rowId: "row-b", + values: { name: "Beta", tags: "done", status: "false" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const boardView = container.querySelector( + `[data-block-id="db-board"] .pen-db-board-view`, + ) as HTMLDivElement | null; + expect(boardView).not.toBeNull(); + + const laneHeaders = Array.from( + container.querySelectorAll(`[data-block-id="db-board"] .pen-db-board-lane-header`), + ) as HTMLDivElement[]; + expect(laneHeaders).toHaveLength(2); + expect(laneHeaders[0]?.textContent).toContain("Todo (1)"); + expect(laneHeaders[1]?.textContent).toContain("Done (1)"); + + const boardCard = container.querySelector( + `[data-block-id="db-board"] .pen-db-board-card[data-row="0"]`, + ) as HTMLDivElement | null; + expect(boardCard).not.toBeNull(); + expect(boardCard?.textContent).toContain("Alpha"); + expect(boardCard?.textContent).toContain("Status"); + + await unmountDatabase(root, container, editor); + }); + + it("renders gallery views as row cards", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-gallery", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-add-view", + blockId: "db-gallery", + view: { + id: "view-gallery", + title: "Gallery view", + type: "gallery", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: null, + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-gallery", + viewId: "view-gallery", + }, + { + type: "database-insert-row", + blockId: "db-gallery", + rowId: "row-a", + values: { name: "Alpha", tags: "Todo", status: "true" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const galleryView = container.querySelector( + `[data-block-id="db-gallery"] .pen-db-gallery-view`, + ) as HTMLDivElement | null; + expect(galleryView).not.toBeNull(); + + const galleryCard = container.querySelector( + `[data-block-id="db-gallery"] .pen-db-gallery-card[data-row="0"]`, + ) as HTMLDivElement | null; + expect(galleryCard).not.toBeNull(); + expect(galleryCard?.textContent).toContain("Name"); + expect(galleryCard?.textContent).toContain("Alpha"); + expect(galleryCard?.textContent).toContain("Tags"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part11.test.tsx b/packages/extensions/database/src/__tests__/renderer.part11.test.tsx new file mode 100644 index 0000000..c2e47c1 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part11.test.tsx @@ -0,0 +1,291 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("renders calendar views from the first date column", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-calendar", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-calendar", + columnId: "tags", + patch: { + title: "Due", + }, + }, + { + type: "database-convert-column", + blockId: "db-calendar", + columnId: "tags", + toType: "date", + }, + { + type: "database-add-view", + blockId: "db-calendar", + view: { + id: "view-calendar", + title: "Calendar view", + type: "calendar", + visibleColumnIds: ["name", "tags", "status"], + columnOrder: ["name", "tags", "status"], + sort: [], + filter: null, + groupBy: null, + pageIndex: 0, + pageSize: 50, + }, + }, + { + type: "database-set-active-view", + blockId: "db-calendar", + viewId: "view-calendar", + }, + { + type: "database-insert-row", + blockId: "db-calendar", + rowId: "row-a", + values: { name: "Alpha", tags: "2024-03-10T09:00:00.000Z", status: "true" }, + }, + { + type: "database-insert-row", + blockId: "db-calendar", + rowId: "row-b", + values: { name: "Beta", tags: "", status: "false" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + await act(async () => { + await flushAnimationFrames(2); + }); + + const calendarView = container.querySelector( + `[data-block-id="db-calendar"] .pen-db-calendar-view`, + ) as HTMLDivElement | null; + expect(calendarView).not.toBeNull(); + + const calendarCards = Array.from( + container.querySelectorAll( + `[data-block-id="db-calendar"] .pen-db-calendar-view .pen-db-calendar-card`, + ), + ) as HTMLDivElement[]; + expect( + calendarCards.some((card) => card.textContent?.includes("Alpha")), + ).toBe(true); + + const unscheduledSection = container.querySelector( + `[data-block-id="db-calendar"] .pen-db-calendar-unscheduled`, + ) as HTMLDivElement | null; + expect(unscheduledSection).not.toBeNull(); + expect(unscheduledSection?.textContent).toContain("Beta"); + + await unmountDatabase(root, container, editor); + }); +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part2.test.tsx b/packages/extensions/database/src/__tests__/renderer.part2.test.tsx new file mode 100644 index 0000000..4839fae --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part2.test.tsx @@ -0,0 +1,447 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("uses the block default column width for implicit and newly added columns", async () => { + const editor = createEditor({ + }); + + editor.apply([ + { + type: "insert-block", + blockId: "db-custom-width", + blockType: "database", + props: { defaultColumnWidth: 220 }, + position: "last", + }, + { + type: "database-insert-row", + blockId: "db-custom-width", + rowId: "row-1", + values: { + name: "Task", + }, + }, + ]); + + const { container, root } = await renderDatabase(editor); + + const headerCellsBeforeInsert = container.querySelectorAll( + `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, + ); + expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.minWidth).toBe("220px"); + expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.maxWidth).toBe("220px"); + + const addColumnButton = container.querySelector( + ".pen-table-add-column-control", + ) as HTMLButtonElement | null; + expect(addColumnButton).not.toBeNull(); + + await act(async () => { + addColumnButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + const headerCellsAfterInsert = container.querySelectorAll( + `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, + ); + expect(headerCellsAfterInsert).toHaveLength(4); + expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.minWidth).toBe("220px"); + expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.maxWidth).toBe("220px"); + + await unmountDatabase(root, container, editor); + }); + + it("deletes selected rows when delete is pressed from a row checkbox", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-delete-rows", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "update-table-columns", + blockId: "db-delete-rows", + columns: [ + { id: "name", title: "Name", type: "text" }, + { id: "status", title: "Status", type: "checkbox" }, + ], + }, + { + type: "database-insert-row", + blockId: "db-delete-rows", + rowId: "row-alpha", + values: { name: "Alpha", status: "true" }, + }, + { + type: "database-insert-row", + blockId: "db-delete-rows", + rowId: "row-beta", + values: { name: "Beta", status: "false" }, + }, + ]); + + const { container, root } = await renderDatabase(editor); + const tableRows = Array.from( + container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + const alphaRow = tableRows.find((row) => row.textContent?.includes("Alpha")) ?? null; + const rowCheckbox = alphaRow?.querySelector( + `input[type="checkbox"]`, + ) as HTMLInputElement | null; + expect(alphaRow).not.toBeNull(); + expect(rowCheckbox).not.toBeNull(); + + await act(async () => { + rowCheckbox?.focus(); + rowCheckbox?.click(); + await flushAnimationFrames(2); + }); + + const liveAlphaRow = Array.from( + container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), + ).find((row) => row.textContent?.includes("Alpha")) as HTMLTableRowElement | undefined; + const liveRowCheckbox = liveAlphaRow?.querySelector( + `input[type="checkbox"]`, + ) as HTMLInputElement | null; + expect(liveRowCheckbox?.checked).toBe(true); + const blockBeforeDelete = editor.getBlock("db-delete-rows"); + const rowCountBeforeDelete = blockBeforeDelete?.tableRowCount() ?? 0; + expect(rowCountBeforeDelete).toBeGreaterThan(1); + + await act(async () => { + liveRowCheckbox?.focus(); + await flushAnimationFrames(1); + }); + expect(document.activeElement).toBe(liveRowCheckbox); + + await act(async () => { + liveRowCheckbox?.dispatchEvent(createKeyEvent("Delete")); + await flushAnimationFrames(2); + }); + + const block = editor.getBlock("db-delete-rows"); + expect(block?.tableRowCount()).toBe(rowCountBeforeDelete - 1); + const renderedRowsAfterDelete = Array.from( + container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), + ).map((row) => row.textContent ?? ""); + expect(renderedRowsAfterDelete.some((text) => text.includes("Alpha"))).toBe(false); + expect(renderedRowsAfterDelete.some((text) => text.includes("Beta"))).toBe(true); + + await unmountDatabase(root, container, editor); + }); + + it("navigates visible sorted rows instead of storage order", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-visible-rows", + [ + { id: "name", title: "Name", type: "text" }, + { id: "score", title: "Score", type: "number" }, + { id: "status", title: "Status", type: "text" }, + ], + [ + ["Alpha", "2", "keep"], + ["Beta", "1", "skip"], + ["Gamma", "3", "keep"], + ], + ); + updatePrimaryView(editor, "db-nav-visible-rows", { + sort: [{ columnId: "score", direction: "desc" }], + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-nav-visible-rows"]`, + ) as HTMLElement | null; + const bodyCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-visible-rows"] tbody td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const firstBodyCell = bodyCells[0] ?? null; + expect(firstBodyCell?.textContent).toContain("Gamma"); + + await act(async () => { + firstBodyCell?.dispatchEvent(createMouseEvent("mousedown")); + firstBodyCell?.dispatchEvent(createMouseEvent("mouseup")); + databaseBlock?.focus(); + await flushAnimationFrames(2); + }); + + await act(async () => { + document.dispatchEvent(createKeyEvent("ArrowDown")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-visible-rows", + head: { row: 1, col: 0 }, + rowIds: [ + "db-nav-visible-rows-row-2", + "db-nav-visible-rows-row-0", + "db-nav-visible-rows-row-1", + ], + }); + + await unmountDatabase(root, container, editor); + }); + + it("skips hidden columns and respects pinned column order when tabbing", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-columns", + [ + { id: "name", title: "Name", type: "text" }, + { id: "hidden", title: "Hidden", type: "text" }, + { id: "pinned", title: "Pinned", type: "text", pinned: "left" }, + ], + [["Alpha", "secret", "Lead"]], + ); + updatePrimaryView(editor, "db-nav-columns", { + visibleColumnIds: ["name", "pinned"], + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-nav-columns"]`, + ) as HTMLElement | null; + const firstRowCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-columns"] tbody tr[data-row] td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const firstVisibleCell = firstRowCells[0] ?? null; + expect(firstVisibleCell?.textContent).toContain("Lead"); + + await act(async () => { + firstVisibleCell?.dispatchEvent(createMouseEvent("mousedown")); + firstVisibleCell?.dispatchEvent(createMouseEvent("mouseup")); + databaseBlock?.focus(); + await flushAnimationFrames(2); + }); + + await act(async () => { + document.dispatchEvent(createKeyEvent("Tab")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-columns", + head: { row: 0, col: 1 }, + columnIds: ["pinned", "name"], + }); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part3.test.tsx b/packages/extensions/database/src/__tests__/renderer.part3.test.tsx new file mode 100644 index 0000000..a1cc630 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part3.test.tsx @@ -0,0 +1,388 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("moves through pinned and grouped rows in rendered order", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-grouped", + [ + { id: "name", title: "Name", type: "text" }, + { id: "status", title: "Status", type: "text" }, + ], + [ + ["Pinned", "todo"], + ["Alpha", "done"], + ["Beta", "todo"], + ], + ); + updatePrimaryView(editor, "db-nav-grouped", { + groupBy: "status", + rowPinning: { + top: ["db-nav-grouped-row-0"], + bottom: [], + }, + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-nav-grouped"]`, + ) as HTMLElement | null; + const groupedCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-grouped"] tbody td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const firstGroupedCell = groupedCells[0] ?? null; + expect(firstGroupedCell?.textContent).toContain("Pinned"); + + await act(async () => { + firstGroupedCell?.dispatchEvent(createMouseEvent("mousedown")); + firstGroupedCell?.dispatchEvent(createMouseEvent("mouseup")); + databaseBlock?.focus(); + await flushAnimationFrames(2); + }); + + await act(async () => { + document.dispatchEvent(createKeyEvent("ArrowDown")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-grouped", + head: { row: 1, col: 0 }, + rowIds: [ + "db-nav-grouped-row-0", + "db-nav-grouped-row-1", + "db-nav-grouped-row-2", + ], + }); + + await unmountDatabase(root, container, editor); + }); + + it("re-normalizes cell selection to the current page", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-nav-page", + [ + { id: "name", title: "Name", type: "text" }, + ], + [ + ["Alpha"], + ["Beta"], + ], + ); + updatePrimaryView(editor, "db-nav-page", { + pageSize: 1, + pageIndex: 1, + }); + + const { container, root } = await renderDatabase(editor); + const previousPageButton = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-page"] .pen-db-pagination button`, + ), + )[0] as HTMLButtonElement | undefined; + const pageCells = Array.from( + container.querySelectorAll( + `[data-block-id="db-nav-page"] tbody td[data-pen-table-cell]`, + ), + ) as HTMLTableCellElement[]; + const secondPageCell = pageCells[0] ?? null; + expect(secondPageCell?.textContent).toContain("Beta"); + + await act(async () => { + secondPageCell?.dispatchEvent(createMouseEvent("mousedown")); + secondPageCell?.dispatchEvent(createMouseEvent("mouseup")); + await flushAnimationFrames(2); + }); + await act(async () => { + previousPageButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toMatchObject({ + type: "cell", + blockId: "db-nav-page", + head: { row: 0, col: 0 }, + rowIds: ["db-nav-page-row-0"], + }); + + await unmountDatabase(root, container, editor); + }); + + it("keeps cmd+a block-scoped for selected databases in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db2", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const databaseBlock = container.querySelector( + `[data-block-id="db2"]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db2"); + databaseBlock?.focus(); + }); + + await act(async () => { + document.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db2"], + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part4.test.tsx b/packages/extensions/database/src/__tests__/renderer.part4.test.tsx new file mode 100644 index 0000000..6d87c7a --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part4.test.tsx @@ -0,0 +1,416 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("falls back to block selection when dragging from a database into text in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db-drag-flow", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-drag-flow"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + const docWithCaretRange = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + }; + const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; + docWithCaretRange.caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); + range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); + return range; + }; + + await act(async () => { + databaseBlock?.dispatchEvent( + createMouseEvent("mousedown", { + detail: 1, + clientX: 10, + clientY: 10, + }), + ); + paragraphInline?.dispatchEvent( + createMouseEvent("mouseup", { + detail: 1, + clientX: 60, + clientY: 40, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-drag-flow", paragraphId], + }); + + docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; + + await unmountDatabase(root, container, editor); + }); + + it("falls back to block selection when dragging from a database into text in structured documents", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-drag-structured", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-drag-structured"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + const docWithCaretRange = document as Document & { + caretRangeFromPoint?: (x: number, y: number) => Range | null; + }; + const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; + docWithCaretRange.caretRangeFromPoint = () => { + const range = document.createRange(); + range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); + range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); + return range; + }; + + await act(async () => { + databaseBlock?.dispatchEvent( + createMouseEvent("mousedown", { + detail: 1, + clientX: 10, + clientY: 10, + }), + ); + paragraphInline?.dispatchEvent( + createMouseEvent("mouseup", { + detail: 1, + clientX: 60, + clientY: 40, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-drag-structured", paragraphId], + }); + + docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; + + await unmountDatabase(root, container, editor); + }); + + it("falls back to block selection when shift-clicking from a database into text in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db-shift-flow", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-shift-flow"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-shift-flow"); + databaseBlock?.focus(); + paragraphInline?.dispatchEvent( + createMouseEvent("click", { + detail: 1, + shiftKey: true, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-shift-flow", paragraphId], + }); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part5.test.tsx b/packages/extensions/database/src/__tests__/renderer.part5.test.tsx new file mode 100644 index 0000000..0553ac5 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part5.test.tsx @@ -0,0 +1,387 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("falls back to block selection when shift-clicking from a database into text in structured documents", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-shift-structured", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-shift-structured"]`, + ) as HTMLElement | null; + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-shift-structured"); + databaseBlock?.focus(); + paragraphInline?.dispatchEvent( + createMouseEvent("click", { + detail: 1, + shiftKey: true, + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-shift-structured", paragraphId], + }); + + await unmountDatabase(root, container, editor); + }); + + it("keeps block-first cmd+a copy scoped to the selected database when block-first interaction is enabled", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + const clipboardData = createClipboardData(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-copy-structured", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor, { + interactionModel: "block-first", + }); + const databaseBlock = container.querySelector( + `[data-block-id="db-copy-structured"]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-copy-structured"); + databaseBlock?.focus(); + }); + + await act(async () => { + document.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-copy-structured"], + }); + + handleCopy(editor, { clipboardData } as ClipboardEvent); + + const penBlocks = JSON.parse( + clipboardData.getData("application/x-pen-blocks"), + ) as Array<{ type: string }>; + + expect(penBlocks.map((block) => block.type)).toEqual(["database"]); + expect(clipboardData.getData("text/plain")).not.toContain("After"); + + await unmountDatabase(root, container, editor); + }); + + it("keeps cmd+a copy scoped to the selected database in flow documents", async () => { + const paragraphId = crypto.randomUUID(); + const clipboardData = createClipboardData(); + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db-copy-flow", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + }); + + const { container, root } = await renderDatabase(editor); + const databaseBlock = container.querySelector( + `[data-block-id="db-copy-flow"]`, + ) as HTMLElement | null; + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + editor.selectBlock("db-copy-flow"); + databaseBlock?.focus(); + }); + + await act(async () => { + document.dispatchEvent(createSelectAllEvent()); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-copy-flow"], + }); + + handleCopy(editor, { clipboardData } as ClipboardEvent); + + const penBlocks = JSON.parse( + clipboardData.getData("application/x-pen-blocks"), + ) as Array<{ type: string }>; + + expect(penBlocks.map((block) => block.type)).toEqual(["database"]); + expect(clipboardData.getData("text/plain")).not.toContain("After"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part6.test.tsx b/packages/extensions/database/src/__tests__/renderer.part6.test.tsx new file mode 100644 index 0000000..992c9f4 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part6.test.tsx @@ -0,0 +1,441 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("promotes beforeinput backspace into a selected database that can be deleted", async () => { + const editor = createEditor({ + }); + const paragraphId = crypto.randomUUID(); + + editor.apply([ + { + type: "insert-block", + blockId: "db-backspace", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "insert-block", + blockId: paragraphId, + blockType: "paragraph", + props: {}, + position: "last", + }, + { + type: "insert-text", + blockId: paragraphId, + offset: 0, + text: "After", + }, + ]); + + const { container, root } = await renderDatabase(editor); + const fieldEditor = getAttachedFieldEditor(editor); + const paragraphInline = container.querySelector( + `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, + ) as HTMLElement | null; + const databaseBlock = container.querySelector( + `[data-block-id="db-backspace"]`, + ) as HTMLElement | null; + + expect(fieldEditor).not.toBeNull(); + expect(paragraphInline).not.toBeNull(); + expect(databaseBlock).not.toBeNull(); + + await act(async () => { + fieldEditor?.activateTextSelection?.(paragraphId, 0, 0); + await flushAnimationFrames(2); + }); + + await act(async () => { + paragraphInline?.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "deleteContentBackward", + }), + ); + await flushAnimationFrames(2); + }); + + expect(editor.selection).toEqual({ + type: "block", + blockIds: ["db-backspace"], + }); + expect(databaseBlock?.getAttribute("data-selected")).toBe("true"); + expect( + databaseBlock + ?.querySelector("[data-pen-table-frame]") + ?.getAttribute("data-selected"), + ).toBe("true"); + + await act(async () => { + document.dispatchEvent(createKeyEvent("Backspace")); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-backspace")).toBeNull(); + expect(editor.getBlock(paragraphId)).not.toBeNull(); + + await unmountDatabase(root, container, editor); + }); + + it("supports multi-sort via shift-click on column headers", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-sort", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "tags", title: "Priority", type: "number", width: 120 }, + ], + [["A", "2"], ["B", "1"]], + ); + const { container, root } = await renderDatabase(editor); + + const nameHeader = container.querySelector( + `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="0"]`, + ) as HTMLElement | null; + const priorityHeader = container.querySelector( + `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="1"]`, + ) as HTMLElement | null; + expect(nameHeader).not.toBeNull(); + expect(priorityHeader).not.toBeNull(); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "asc" }, + ]); + + await act(async () => { + priorityHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "asc" }, + { columnId: "tags", direction: "asc" }, + ]); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "desc" }, + { columnId: "tags", direction: "asc" }, + ]); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); + await flushAnimationFrames(1); + }); + expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ + { columnId: "tags", direction: "asc" }, + ]); + + await unmountDatabase(root, container, editor); + }); + + it("keeps column header controls out of editor selection gestures", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-header-controls", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "tags", title: "Priority", type: "number", width: 120 }, + ], + [["A", "2"], ["B", "1"]], + ); + const { container, root } = await renderDatabase(editor); + + const nameHeader = container.querySelector( + `[data-block-id="db-header-controls"] [data-cell-row="0"][data-cell-col="0"]`, + ) as HTMLElement | null; + const menuButton = container.querySelector( + `[data-block-id="db-header-controls"] .pen-db-col-menu-btn`, + ) as HTMLButtonElement | null; + expect(nameHeader).not.toBeNull(); + expect(menuButton).not.toBeNull(); + expect(editor.selection).toBeNull(); + + await act(async () => { + nameHeader?.dispatchEvent(createMouseEvent("mousedown")); + nameHeader?.dispatchEvent(createMouseEvent("mouseup")); + nameHeader?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-header-controls")?.databaseActiveView()?.sort).toEqual([ + { columnId: "name", direction: "asc" }, + ]); + expect(editor.selection).toBeNull(); + + await act(async () => { + menuButton?.dispatchEvent(createMouseEvent("mousedown")); + menuButton?.dispatchEvent(createMouseEvent("mouseup")); + menuButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const renameInput = container.querySelector( + `[data-block-id="db-header-controls"] .pen-db-col-rename-input`, + ) as HTMLInputElement | null; + expect(renameInput).not.toBeNull(); + + await act(async () => { + renameInput?.dispatchEvent(createMouseEvent("mousedown")); + renameInput?.dispatchEvent(createMouseEvent("mouseup")); + renameInput?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.selection).toBeNull(); + + await unmountDatabase(root, container, editor); + }); + + it("applies sticky left and right pin styles to pinned columns", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-pins", + [ + { id: "name", title: "Name", type: "text", width: 120, pinned: "left" }, + { id: "tags", title: "Status", type: "text", width: 120 }, + { id: "status", title: "Due", type: "text", width: 140, pinned: "right" }, + ], + [["A", "Open", "Soon"]], + ); + const { container, root } = await renderDatabase(editor); + + const leftHeader = container.querySelector( + `[data-block-id="db-pins"] th[data-cell-col="0"]`, + ) as HTMLTableCellElement | null; + const rightHeader = container.querySelector( + `[data-block-id="db-pins"] th[data-cell-col="2"]`, + ) as HTMLTableCellElement | null; + const leftCell = container.querySelector( + `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="0"]`, + ) as HTMLTableCellElement | null; + const rightCell = container.querySelector( + `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="2"]`, + ) as HTMLTableCellElement | null; + + expect(leftHeader?.style.position).toBe("sticky"); + expect(leftHeader?.style.left).toBe("44px"); + expect(rightHeader?.style.position).toBe("sticky"); + expect(rightHeader?.style.right).toBe("0px"); + expect(leftCell?.style.left).toBe("44px"); + expect(rightCell?.style.right).toBe("0px"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part7.test.tsx b/packages/extensions/database/src/__tests__/renderer.part7.test.tsx new file mode 100644 index 0000000..a5d758a --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part7.test.tsx @@ -0,0 +1,395 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("shows facet-backed autocomplete options in the filter panel", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-filter", + [ + { + id: "status", + title: "Status", + type: "select", + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + ], + [["todo"], ["done"], ["todo"]], + ); + const { container, root } = await renderDatabase(editor); + + const filterButton = getButtonByText(container, "Filter"); + expect(filterButton).not.toBeNull(); + + await act(async () => { + filterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; + expect(addFilterButton).not.toBeNull(); + + await act(async () => { + addFilterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const datalist = container.querySelector('datalist[id="pen-db-filter-values-0"]'); + const todoOption = datalist?.querySelector('option[value="todo"]') as HTMLOptionElement | null; + const doneOption = datalist?.querySelector('option[value="done"]') as HTMLOptionElement | null; + expect(todoOption?.label).toBe("Todo (2)"); + expect(doneOption?.label).toBe("Done (1)"); + + await unmountDatabase(root, container, editor); + }); + + it("manages the multi-sort stack from the sort panel", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-sort-panel", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "tags", title: "Priority", type: "number", width: 120 }, + { id: "status", title: "Status", type: "text", width: 120 }, + ], + [["A", "2", "Open"], ["B", "1", "Done"]], + ); + const { container, root } = await renderDatabase(editor); + + const sortButton = getButtonByText(container, "Sort"); + expect(sortButton).not.toBeNull(); + + await act(async () => { + sortButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; + expect(addSortButton).not.toBeNull(); + + await act(async () => { + addSortButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const refreshedAddSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; + expect(refreshedAddSortButton).not.toBeNull(); + + await act(async () => { + refreshedAddSortButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const secondColumnSelect = container.querySelector('[data-sort-column="1"]') as HTMLSelectElement | null; + expect(secondColumnSelect).not.toBeNull(); + + await act(async () => { + if (secondColumnSelect) { + secondColumnSelect.value = "tags"; + secondColumnSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(1); + }); + + const refreshedSecondDirectionSelect = container.querySelector( + '[data-sort-direction="1"]', + ) as HTMLSelectElement | null; + expect(refreshedSecondDirectionSelect).not.toBeNull(); + + await act(async () => { + if (refreshedSecondDirectionSelect) { + refreshedSecondDirectionSelect.value = "desc"; + refreshedSecondDirectionSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(1); + }); + + const moveUpButton = container.querySelector('[data-sort-move-up="1"]') as HTMLButtonElement | null; + expect(moveUpButton).not.toBeNull(); + + await act(async () => { + moveUpButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-sort-panel")?.databaseActiveView()?.sort).toEqual([ + { columnId: "tags", direction: "desc" }, + { columnId: "name", direction: "asc" }, + ]); + + await unmountDatabase(root, container, editor); + }); + + it("supports nested filter groups from the filter panel", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-filter-groups", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "status", title: "Status", type: "text", width: 120 }, + ], + [["Alpha", "Open"], ["Beta", "Done"]], + ); + const { container, root } = await renderDatabase(editor); + + const filterButton = getButtonByText(container, "Filter"); + expect(filterButton).not.toBeNull(); + + await act(async () => { + filterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addGroupButton = container.querySelector('[data-filter-add-group="root"]') as HTMLButtonElement | null; + expect(addGroupButton).not.toBeNull(); + + await act(async () => { + addGroupButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const nestedValueInput = container.querySelector('[data-filter-value="0-0"]') as HTMLInputElement | null; + expect(nestedValueInput).not.toBeNull(); + + await act(async () => { + if (nestedValueInput) { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + valueSetter?.call(nestedValueInput, "Alpha"); + nestedValueInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Alpha" })); + } + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-filter-groups")?.databaseActiveView()?.filter).toEqual({ + operator: "and", + conditions: [ + { + operator: "and", + conditions: [ + { columnId: "name", operator: "contains", value: "Alpha" }, + ], + }, + ], + }); + + const renderedRows = Array.from( + container.querySelectorAll(`[data-block-id="db-filter-groups"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + expect(renderedRows).toHaveLength(1); + expect(renderedRows[0]?.textContent).toContain("Alpha"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part8.test.tsx b/packages/extensions/database/src/__tests__/renderer.part8.test.tsx new file mode 100644 index 0000000..5662b7f --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part8.test.tsx @@ -0,0 +1,409 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("filters dates with relative presets from the filter panel", async () => { + const now = new Date(); + const recentDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2, 9, 0, 0); + const oldDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 12, 9, 0, 0); + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-date-filter", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-date-filter", + columnId: "tags", + patch: { + title: "Due", + }, + }, + { + type: "database-convert-column", + blockId: "db-date-filter", + columnId: "tags", + toType: "date", + }, + { + type: "database-insert-row", + blockId: "db-date-filter", + rowId: "row-a", + values: { name: "Alpha", tags: recentDate.toISOString() }, + }, + { + type: "database-insert-row", + blockId: "db-date-filter", + rowId: "row-b", + values: { name: "Beta", tags: oldDate.toISOString() }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const filterButton = getButtonByText(container, "Filter"); + expect(filterButton).not.toBeNull(); + + await act(async () => { + filterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; + expect(addFilterButton).not.toBeNull(); + + await act(async () => { + addFilterButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const columnSelect = container.querySelector('[data-filter-column="0"]') as HTMLSelectElement | null; + expect(columnSelect).not.toBeNull(); + + await act(async () => { + if (columnSelect) { + columnSelect.value = "tags"; + columnSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(2); + }); + + const operatorSelect = container.querySelector('[data-filter-operator="0"]') as HTMLSelectElement | null; + expect(operatorSelect).not.toBeNull(); + expect( + Array.from(operatorSelect?.options ?? []).some( + (option) => option.value === "is_relative", + ), + ).toBe(true); + + updatePrimaryView(editor, "db-date-filter", { + filter: { + operator: "and", + conditions: [{ + columnId: "tags", + operator: "is_relative", + value: "last_7_days", + }], + }, + }); + + await act(async () => { + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-date-filter")?.databaseActiveView()?.filter).toEqual({ + operator: "and", + conditions: [{ + columnId: "tags", + operator: "is_relative", + value: "last_7_days", + }], + }); + + await unmountDatabase(root, container, editor); + }); + + it("pins selected rows to the top and bottom through the toolbar", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-row-pins", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-insert-row", + blockId: "db-row-pins", + rowId: "row-a", + values: { name: "Alpha" }, + }, + { + type: "database-insert-row", + blockId: "db-row-pins", + rowId: "row-b", + values: { name: "Beta" }, + }, + { + type: "database-insert-row", + blockId: "db-row-pins", + rowId: "row-c", + values: { name: "Gamma" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const rowCheckboxes = Array.from( + container.querySelectorAll( + `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, + ), + ) as HTMLInputElement[]; + expect(rowCheckboxes).toHaveLength(3); + + await act(async () => { + rowCheckboxes[1]?.click(); + await flushAnimationFrames(1); + }); + + const pinTopButton = getButtonByText(container, "Pin top"); + expect(pinTopButton).not.toBeNull(); + + await act(async () => { + pinTopButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ + top: ["row-b"], + }); + + let renderedRows = Array.from( + container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + expect(renderedRows[0]?.getAttribute("data-row-section")).toBe("top"); + expect(renderedRows[0]?.textContent).toContain("Beta"); + + const refreshedRowCheckboxes = Array.from( + container.querySelectorAll( + `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, + ), + ) as HTMLInputElement[]; + + await act(async () => { + refreshedRowCheckboxes[0]?.click(); + await flushAnimationFrames(1); + }); + + await act(async () => { + refreshedRowCheckboxes[2]?.click(); + await flushAnimationFrames(1); + }); + + const pinBottomButton = getButtonByText(container, "Pin bottom"); + expect(pinBottomButton).not.toBeNull(); + + await act(async () => { + pinBottomButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ + top: ["row-b"], + bottom: ["row-c"], + }); + + renderedRows = Array.from( + container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), + ) as HTMLTableRowElement[]; + expect(renderedRows.at(-1)?.getAttribute("data-row-section")).toBe("bottom"); + expect(renderedRows.at(-1)?.textContent).toContain("Gamma"); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.part9.test.tsx b/packages/extensions/database/src/__tests__/renderer.part9.test.tsx new file mode 100644 index 0000000..04b34a8 --- /dev/null +++ b/packages/extensions/database/src/__tests__/renderer.part9.test.tsx @@ -0,0 +1,425 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor as createCoreEditor } from "@pen/core"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import { Pen, getAttachedFieldEditor, handleCopy } from "@pen/react"; +import { DatabaseRenderer } from "../renderer"; +import { ColumnMenu } from "../rendererPanels"; +import { useDatabaseController } from "../useDatabaseController"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function createEditor( + options: Parameters[0] = {}, +) { + const { without: _without, ...restOptions } = options; + return createCoreEditor({ + ...restOptions, + preset: noDefaultExtensionsPreset, + }); +} + +function createMouseEvent( + type: string, + options: MouseEventInit = {}, +): MouseEvent { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, + ...options, + }); +} + +function createSelectAllEvent(): KeyboardEvent { + return new KeyboardEvent("keydown", { + key: "a", + metaKey: true, + bubbles: true, + cancelable: true, + }); +} + +function createKeyEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createClipboardData(): DataTransfer { + const data = new Map(); + + return { + files: [] as unknown as FileList, + types: [], + getData(type: string) { + return data.get(type) ?? ""; + }, + setData(type: string, value: string) { + data.set(type, value); + }, + } as unknown as DataTransfer; +} + +async function flushAnimationFrames(count = 1): Promise { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } +} + +async function renderDatabase( + editor: ReturnType, + options?: { + children?: React.ReactNode; + interactionModel?: "content-first" | "block-first"; + }, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + {options?.children} + , + ); + }); + + return { container, root }; +} + +async function unmountDatabase( + root: ReturnType, + container: HTMLDivElement, + editor: ReturnType, +) { + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +): ReturnType { + const bootstrapEditor = createEditor({ + }); + const document = bootstrapEditor.internals.adapter.createDocument(); + bootstrapEditor.destroy(); + + const seedEditor = createEditor({ + document, + }); + seed(seedEditor); + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + seedEditor.destroy(); + + return createEditor({ + document, + }); +} + +function seedDatabase( + editor: ReturnType, + blockId: string, + columns: TableColumnSchema[], + rows: Array, +) { + editor.apply([ + { + type: "insert-block", + blockId, + blockType: "database", + props: {}, + position: "last", + }, + ]); + editor.apply([{ + type: "update-table-columns", + blockId, + columns, + }]); + rows.forEach((values, rowIndex) => { + editor.apply([{ + type: "database-insert-row", + blockId, + index: rowIndex, + rowId: `${blockId}-row-${rowIndex}`, + values: Object.fromEntries( + columns.map((column, colIndex) => [column.id, values[colIndex] ?? ""]), + ), + }]); + }); +} + +function updatePrimaryView( + editor: ReturnType, + blockId: string, + patch: Partial>, +) { + act(() => { + const block = editor.getBlock(blockId); + editor.apply([{ + type: "database-update-view", + blockId, + viewId: block?.databasePrimaryViewId() ?? undefined, + patch, + }], { origin: "user" }); + }); +} + +function getButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + const buttons = Array.from(container.querySelectorAll("button")); + return (buttons.find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined) ?? null; +} + +describe("@pen/database renderer", () => { + it("refreshes the open column menu after adding a select option", async () => { + const editor = createEditor({ + }); + + function OptionMutationHarness() { + const db = useDatabaseController({ blockId: "db-option-menu" }); + const statusColumn = db.columnSchema.find((entry) => entry.id === "status"); + return ( + <> + + { }} + onRename={(nextTitle) => db.renameColumn("status", nextTitle)} + onChangeType={(nextType) => db.changeColumnType("status", nextType)} + onDelete={() => db.deleteColumn("status")} + onToggleVisibility={() => db.toggleColumnVisibility("status")} + onChangePin={(nextPinned) => db.changeColumnPin("status", nextPinned)} + onAddOption={(value, color) => db.addOption("status", value, color)} + onRenameOption={(optionId, value) => db.renameOption("status", optionId, value)} + onRecolorOption={(optionId, color) => db.recolorOption("status", optionId, color)} + onRemoveOption={(optionId) => db.removeOption("status", optionId)} + onMoveOption={(optionId, direction) => db.moveOption("status", optionId, direction)} + /> + + ); + } + + seedDatabase( + editor, + "db-option-menu", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "status", title: "Status", type: "select", width: 140, options: [] }, + ], + [["Alpha", ""]], + ); + const { container, root } = await renderDatabase( + editor, + { children: }, + ); + + let optionRows = Array.from( + container.querySelectorAll(`.pen-db-col-option-row input`), + ) as HTMLInputElement[]; + expect(optionRows).toHaveLength(0); + + const addOptionButton = getButtonByText(container, "Add test option"); + expect(addOptionButton).not.toBeNull(); + + await act(async () => { + addOptionButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-option-menu")?.tableColumns()[1]?.options).toEqual([ + expect.objectContaining({ + value: "Blocked", + color: "gray", + }), + ]); + + optionRows = Array.from( + container.querySelectorAll(`.pen-db-col-option-row input`), + ) as HTMLInputElement[]; + expect(optionRows).toHaveLength(1); + expect(optionRows[0]?.value).toBe("Blocked"); + + await unmountDatabase(root, container, editor); + }); + + it("renders grouped sections from the group panel", async () => { + const editor = createEditor({ + }); + editor.apply([ + { + type: "insert-block", + blockId: "db-group", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "database-update-column", + blockId: "db-group", + columnId: "tags", + patch: { + title: "Status", + options: [ + { id: "todo", value: "Todo" }, + { id: "done", value: "Done" }, + ], + }, + }, + { + type: "database-insert-row", + blockId: "db-group", + rowId: "row-a", + values: { name: "Alpha", tags: "todo" }, + }, + { + type: "database-insert-row", + blockId: "db-group", + rowId: "row-b", + values: { name: "Beta", tags: "done" }, + }, + { + type: "database-insert-row", + blockId: "db-group", + rowId: "row-c", + values: { name: "Gamma", tags: "todo" }, + }, + ]); + const { container, root } = await renderDatabase(editor); + + const groupButton = getButtonByText(container, "Group"); + expect(groupButton).not.toBeNull(); + + await act(async () => { + groupButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const groupSelect = container.querySelector(".pen-db-col-vis-panel select") as HTMLSelectElement | null; + expect(groupSelect).not.toBeNull(); + + await act(async () => { + if (groupSelect) { + groupSelect.value = "tags"; + groupSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-group")?.databaseActiveView()?.groupBy).toBe("tags"); + + const groupRows = Array.from( + container.querySelectorAll(`[data-block-id="db-group"] .pen-db-group-row`), + ) as HTMLTableRowElement[]; + expect(groupRows).toHaveLength(2); + expect(groupRows[0]?.textContent).toContain("Todo (2)"); + expect(groupRows[1]?.textContent).toContain("Done (1)"); + + await unmountDatabase(root, container, editor); + }); + + it("adds switches and removes database views from the title bar", async () => { + const editor = createEditor({ + }); + seedDatabase( + editor, + "db-views", + [ + { id: "name", title: "Name", type: "text", width: 140 }, + { id: "status", title: "Status", type: "text", width: 120 }, + ], + [["Alpha", "Open"], ["Beta", "Done"]], + ); + const primaryViewId = editor.getBlock("db-views")?.databasePrimaryViewId() ?? ""; + const { container, root } = await renderDatabase(editor); + + const addViewButton = getButtonByText(container, "+ View"); + expect(addViewButton).not.toBeNull(); + + await act(async () => { + addViewButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const addListViewButton = getButtonByText(container, "New list view"); + const addBoardViewButton = getButtonByText(container, "New board view"); + const addCalendarViewButton = getButtonByText(container, "New calendar view"); + const addGalleryViewButton = getButtonByText(container, "New gallery view"); + expect(addListViewButton).not.toBeNull(); + expect(addBoardViewButton).not.toBeNull(); + expect(addCalendarViewButton).not.toBeNull(); + expect(addGalleryViewButton).not.toBeNull(); + + await act(async () => { + addListViewButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + const blockAfterAdd = editor.getBlock("db-views"); + const listView = blockAfterAdd?.databaseViews().find((view) => view.type === "list"); + expect(listView).toBeDefined(); + expect(blockAfterAdd?.databaseViews()).toHaveLength(2); + expect(blockAfterAdd?.databaseActiveView()?.id).toBe(listView?.id); + expect(container.querySelector(`[data-block-id="db-views"] .pen-db-list-view`)).not.toBeNull(); + + const tableTab = container.querySelector( + `[data-block-id="db-views"] [data-view-id="${primaryViewId}"]`, + ) as HTMLButtonElement | null; + expect(tableTab).not.toBeNull(); + + await act(async () => { + tableTab?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(2); + }); + + expect(editor.getBlock("db-views")?.databaseActiveView()?.id).toBe(primaryViewId); + expect(container.querySelector(`[data-block-id="db-views"] table[data-pen-table]`)).not.toBeNull(); + + const removeListViewButton = container.querySelector( + `[data-block-id="db-views"] [data-remove-view-id="${listView?.id ?? ""}"]`, + ) as HTMLButtonElement | null; + expect(removeListViewButton).not.toBeNull(); + + await act(async () => { + removeListViewButton?.dispatchEvent(createMouseEvent("click")); + await flushAnimationFrames(1); + }); + + expect(editor.getBlock("db-views")?.databaseViews()).toHaveLength(1); + expect(editor.getBlock("db-views")?.databasePrimaryViewId()).toBe(primaryViewId); + + await unmountDatabase(root, container, editor); + }); + +}); diff --git a/packages/extensions/database/src/__tests__/renderer.test.tsx b/packages/extensions/database/src/__tests__/renderer.test.tsx index c1a2418..6c9c1bd 100644 --- a/packages/extensions/database/src/__tests__/renderer.test.tsx +++ b/packages/extensions/database/src/__tests__/renderer.test.tsx @@ -432,1964 +432,4 @@ describe("@pen/database renderer", () => { await unmountDatabase(root, container, editor); }); - it("uses the block default column width for implicit and newly added columns", async () => { - const editor = createEditor({ - }); - - editor.apply([ - { - type: "insert-block", - blockId: "db-custom-width", - blockType: "database", - props: { defaultColumnWidth: 220 }, - position: "last", - }, - { - type: "database-insert-row", - blockId: "db-custom-width", - rowId: "row-1", - values: { - name: "Task", - }, - }, - ]); - - const { container, root } = await renderDatabase(editor); - - const headerCellsBeforeInsert = container.querySelectorAll( - `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, - ); - expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.minWidth).toBe("220px"); - expect((headerCellsBeforeInsert[0] as HTMLTableCellElement).style.maxWidth).toBe("220px"); - - const addColumnButton = container.querySelector( - ".pen-table-add-column-control", - ) as HTMLButtonElement | null; - expect(addColumnButton).not.toBeNull(); - - await act(async () => { - addColumnButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - const headerCellsAfterInsert = container.querySelectorAll( - `[data-block-id="db-custom-width"] thead th[data-pen-table-cell]`, - ); - expect(headerCellsAfterInsert).toHaveLength(4); - expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.minWidth).toBe("220px"); - expect((headerCellsAfterInsert[3] as HTMLTableCellElement).style.maxWidth).toBe("220px"); - - await unmountDatabase(root, container, editor); - }); - - it("deletes selected rows when delete is pressed from a row checkbox", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-delete-rows", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "update-table-columns", - blockId: "db-delete-rows", - columns: [ - { id: "name", title: "Name", type: "text" }, - { id: "status", title: "Status", type: "checkbox" }, - ], - }, - { - type: "database-insert-row", - blockId: "db-delete-rows", - rowId: "row-alpha", - values: { name: "Alpha", status: "true" }, - }, - { - type: "database-insert-row", - blockId: "db-delete-rows", - rowId: "row-beta", - values: { name: "Beta", status: "false" }, - }, - ]); - - const { container, root } = await renderDatabase(editor); - const tableRows = Array.from( - container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - const alphaRow = tableRows.find((row) => row.textContent?.includes("Alpha")) ?? null; - const rowCheckbox = alphaRow?.querySelector( - `input[type="checkbox"]`, - ) as HTMLInputElement | null; - expect(alphaRow).not.toBeNull(); - expect(rowCheckbox).not.toBeNull(); - - await act(async () => { - rowCheckbox?.focus(); - rowCheckbox?.click(); - await flushAnimationFrames(2); - }); - - const liveAlphaRow = Array.from( - container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), - ).find((row) => row.textContent?.includes("Alpha")) as HTMLTableRowElement | undefined; - const liveRowCheckbox = liveAlphaRow?.querySelector( - `input[type="checkbox"]`, - ) as HTMLInputElement | null; - expect(liveRowCheckbox?.checked).toBe(true); - const blockBeforeDelete = editor.getBlock("db-delete-rows"); - const rowCountBeforeDelete = blockBeforeDelete?.tableRowCount() ?? 0; - expect(rowCountBeforeDelete).toBeGreaterThan(1); - - await act(async () => { - liveRowCheckbox?.focus(); - await flushAnimationFrames(1); - }); - expect(document.activeElement).toBe(liveRowCheckbox); - - await act(async () => { - liveRowCheckbox?.dispatchEvent(createKeyEvent("Delete")); - await flushAnimationFrames(2); - }); - - const block = editor.getBlock("db-delete-rows"); - expect(block?.tableRowCount()).toBe(rowCountBeforeDelete - 1); - const renderedRowsAfterDelete = Array.from( - container.querySelectorAll(`[data-block-id="db-delete-rows"] tbody tr[data-row]`), - ).map((row) => row.textContent ?? ""); - expect(renderedRowsAfterDelete.some((text) => text.includes("Alpha"))).toBe(false); - expect(renderedRowsAfterDelete.some((text) => text.includes("Beta"))).toBe(true); - - await unmountDatabase(root, container, editor); - }); - - it("navigates visible sorted rows instead of storage order", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-visible-rows", - [ - { id: "name", title: "Name", type: "text" }, - { id: "score", title: "Score", type: "number" }, - { id: "status", title: "Status", type: "text" }, - ], - [ - ["Alpha", "2", "keep"], - ["Beta", "1", "skip"], - ["Gamma", "3", "keep"], - ], - ); - updatePrimaryView(editor, "db-nav-visible-rows", { - sort: [{ columnId: "score", direction: "desc" }], - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-nav-visible-rows"]`, - ) as HTMLElement | null; - const bodyCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-visible-rows"] tbody td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const firstBodyCell = bodyCells[0] ?? null; - expect(firstBodyCell?.textContent).toContain("Gamma"); - - await act(async () => { - firstBodyCell?.dispatchEvent(createMouseEvent("mousedown")); - firstBodyCell?.dispatchEvent(createMouseEvent("mouseup")); - databaseBlock?.focus(); - await flushAnimationFrames(2); - }); - - await act(async () => { - document.dispatchEvent(createKeyEvent("ArrowDown")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-visible-rows", - head: { row: 1, col: 0 }, - rowIds: [ - "db-nav-visible-rows-row-2", - "db-nav-visible-rows-row-0", - "db-nav-visible-rows-row-1", - ], - }); - - await unmountDatabase(root, container, editor); - }); - - it("skips hidden columns and respects pinned column order when tabbing", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-columns", - [ - { id: "name", title: "Name", type: "text" }, - { id: "hidden", title: "Hidden", type: "text" }, - { id: "pinned", title: "Pinned", type: "text", pinned: "left" }, - ], - [["Alpha", "secret", "Lead"]], - ); - updatePrimaryView(editor, "db-nav-columns", { - visibleColumnIds: ["name", "pinned"], - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-nav-columns"]`, - ) as HTMLElement | null; - const firstRowCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-columns"] tbody tr[data-row] td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const firstVisibleCell = firstRowCells[0] ?? null; - expect(firstVisibleCell?.textContent).toContain("Lead"); - - await act(async () => { - firstVisibleCell?.dispatchEvent(createMouseEvent("mousedown")); - firstVisibleCell?.dispatchEvent(createMouseEvent("mouseup")); - databaseBlock?.focus(); - await flushAnimationFrames(2); - }); - - await act(async () => { - document.dispatchEvent(createKeyEvent("Tab")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-columns", - head: { row: 0, col: 1 }, - columnIds: ["pinned", "name"], - }); - - await unmountDatabase(root, container, editor); - }); - - it("moves through pinned and grouped rows in rendered order", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-grouped", - [ - { id: "name", title: "Name", type: "text" }, - { id: "status", title: "Status", type: "text" }, - ], - [ - ["Pinned", "todo"], - ["Alpha", "done"], - ["Beta", "todo"], - ], - ); - updatePrimaryView(editor, "db-nav-grouped", { - groupBy: "status", - rowPinning: { - top: ["db-nav-grouped-row-0"], - bottom: [], - }, - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-nav-grouped"]`, - ) as HTMLElement | null; - const groupedCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-grouped"] tbody td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const firstGroupedCell = groupedCells[0] ?? null; - expect(firstGroupedCell?.textContent).toContain("Pinned"); - - await act(async () => { - firstGroupedCell?.dispatchEvent(createMouseEvent("mousedown")); - firstGroupedCell?.dispatchEvent(createMouseEvent("mouseup")); - databaseBlock?.focus(); - await flushAnimationFrames(2); - }); - - await act(async () => { - document.dispatchEvent(createKeyEvent("ArrowDown")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-grouped", - head: { row: 1, col: 0 }, - rowIds: [ - "db-nav-grouped-row-0", - "db-nav-grouped-row-1", - "db-nav-grouped-row-2", - ], - }); - - await unmountDatabase(root, container, editor); - }); - - it("re-normalizes cell selection to the current page", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-nav-page", - [ - { id: "name", title: "Name", type: "text" }, - ], - [ - ["Alpha"], - ["Beta"], - ], - ); - updatePrimaryView(editor, "db-nav-page", { - pageSize: 1, - pageIndex: 1, - }); - - const { container, root } = await renderDatabase(editor); - const previousPageButton = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-page"] .pen-db-pagination button`, - ), - )[0] as HTMLButtonElement | undefined; - const pageCells = Array.from( - container.querySelectorAll( - `[data-block-id="db-nav-page"] tbody td[data-pen-table-cell]`, - ), - ) as HTMLTableCellElement[]; - const secondPageCell = pageCells[0] ?? null; - expect(secondPageCell?.textContent).toContain("Beta"); - - await act(async () => { - secondPageCell?.dispatchEvent(createMouseEvent("mousedown")); - secondPageCell?.dispatchEvent(createMouseEvent("mouseup")); - await flushAnimationFrames(2); - }); - await act(async () => { - previousPageButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toMatchObject({ - type: "cell", - blockId: "db-nav-page", - head: { row: 0, col: 0 }, - rowIds: ["db-nav-page-row-0"], - }); - - await unmountDatabase(root, container, editor); - }); - - it("keeps cmd+a block-scoped for selected databases in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db2", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render( - - - , - ); - }); - - const databaseBlock = container.querySelector( - `[data-block-id="db2"]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db2"); - databaseBlock?.focus(); - }); - - await act(async () => { - document.dispatchEvent(createSelectAllEvent()); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db2"], - }); - - await act(async () => { - root.unmount(); - }); - container.remove(); - editor.destroy(); - }); - - it("falls back to block selection when dragging from a database into text in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db-drag-flow", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-drag-flow"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - const docWithCaretRange = document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - }; - const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; - docWithCaretRange.caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); - range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); - return range; - }; - - await act(async () => { - databaseBlock?.dispatchEvent( - createMouseEvent("mousedown", { - detail: 1, - clientX: 10, - clientY: 10, - }), - ); - paragraphInline?.dispatchEvent( - createMouseEvent("mouseup", { - detail: 1, - clientX: 60, - clientY: 40, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-drag-flow", paragraphId], - }); - - docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; - - await unmountDatabase(root, container, editor); - }); - - it("falls back to block selection when dragging from a database into text in structured documents", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-drag-structured", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-drag-structured"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - const docWithCaretRange = document as Document & { - caretRangeFromPoint?: (x: number, y: number) => Range | null; - }; - const originalCaretRangeFromPoint = docWithCaretRange.caretRangeFromPoint; - docWithCaretRange.caretRangeFromPoint = () => { - const range = document.createRange(); - range.setStart(paragraphInline!.firstChild ?? paragraphInline!, 2); - range.setEnd(paragraphInline!.firstChild ?? paragraphInline!, 2); - return range; - }; - - await act(async () => { - databaseBlock?.dispatchEvent( - createMouseEvent("mousedown", { - detail: 1, - clientX: 10, - clientY: 10, - }), - ); - paragraphInline?.dispatchEvent( - createMouseEvent("mouseup", { - detail: 1, - clientX: 60, - clientY: 40, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-drag-structured", paragraphId], - }); - - docWithCaretRange.caretRangeFromPoint = originalCaretRangeFromPoint; - - await unmountDatabase(root, container, editor); - }); - - it("falls back to block selection when shift-clicking from a database into text in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db-shift-flow", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-shift-flow"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-shift-flow"); - databaseBlock?.focus(); - paragraphInline?.dispatchEvent( - createMouseEvent("click", { - detail: 1, - shiftKey: true, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-shift-flow", paragraphId], - }); - - await unmountDatabase(root, container, editor); - }); - - it("falls back to block selection when shift-clicking from a database into text in structured documents", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-shift-structured", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-shift-structured"]`, - ) as HTMLElement | null; - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-shift-structured"); - databaseBlock?.focus(); - paragraphInline?.dispatchEvent( - createMouseEvent("click", { - detail: 1, - shiftKey: true, - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-shift-structured", paragraphId], - }); - - await unmountDatabase(root, container, editor); - }); - - it("keeps block-first cmd+a copy scoped to the selected database when block-first interaction is enabled", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - const clipboardData = createClipboardData(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-copy-structured", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor, { - interactionModel: "block-first", - }); - const databaseBlock = container.querySelector( - `[data-block-id="db-copy-structured"]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-copy-structured"); - databaseBlock?.focus(); - }); - - await act(async () => { - document.dispatchEvent(createSelectAllEvent()); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-copy-structured"], - }); - - handleCopy(editor, { clipboardData } as ClipboardEvent); - - const penBlocks = JSON.parse( - clipboardData.getData("application/x-pen-blocks"), - ) as Array<{ type: string }>; - - expect(penBlocks.map((block) => block.type)).toEqual(["database"]); - expect(clipboardData.getData("text/plain")).not.toContain("After"); - - await unmountDatabase(root, container, editor); - }); - - it("keeps cmd+a copy scoped to the selected database in flow documents", async () => { - const paragraphId = crypto.randomUUID(); - const clipboardData = createClipboardData(); - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db-copy-flow", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - }); - - const { container, root } = await renderDatabase(editor); - const databaseBlock = container.querySelector( - `[data-block-id="db-copy-flow"]`, - ) as HTMLElement | null; - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - editor.selectBlock("db-copy-flow"); - databaseBlock?.focus(); - }); - - await act(async () => { - document.dispatchEvent(createSelectAllEvent()); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-copy-flow"], - }); - - handleCopy(editor, { clipboardData } as ClipboardEvent); - - const penBlocks = JSON.parse( - clipboardData.getData("application/x-pen-blocks"), - ) as Array<{ type: string }>; - - expect(penBlocks.map((block) => block.type)).toEqual(["database"]); - expect(clipboardData.getData("text/plain")).not.toContain("After"); - - await unmountDatabase(root, container, editor); - }); - - it("promotes beforeinput backspace into a selected database that can be deleted", async () => { - const editor = createEditor({ - }); - const paragraphId = crypto.randomUUID(); - - editor.apply([ - { - type: "insert-block", - blockId: "db-backspace", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "insert-block", - blockId: paragraphId, - blockType: "paragraph", - props: {}, - position: "last", - }, - { - type: "insert-text", - blockId: paragraphId, - offset: 0, - text: "After", - }, - ]); - - const { container, root } = await renderDatabase(editor); - const fieldEditor = getAttachedFieldEditor(editor); - const paragraphInline = container.querySelector( - `[data-block-id="${paragraphId}"] [data-pen-inline-content]`, - ) as HTMLElement | null; - const databaseBlock = container.querySelector( - `[data-block-id="db-backspace"]`, - ) as HTMLElement | null; - - expect(fieldEditor).not.toBeNull(); - expect(paragraphInline).not.toBeNull(); - expect(databaseBlock).not.toBeNull(); - - await act(async () => { - fieldEditor?.activateTextSelection?.(paragraphId, 0, 0); - await flushAnimationFrames(2); - }); - - await act(async () => { - paragraphInline?.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: true, - inputType: "deleteContentBackward", - }), - ); - await flushAnimationFrames(2); - }); - - expect(editor.selection).toEqual({ - type: "block", - blockIds: ["db-backspace"], - }); - expect(databaseBlock?.getAttribute("data-selected")).toBe("true"); - expect( - databaseBlock - ?.querySelector("[data-pen-table-frame]") - ?.getAttribute("data-selected"), - ).toBe("true"); - - await act(async () => { - document.dispatchEvent(createKeyEvent("Backspace")); - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-backspace")).toBeNull(); - expect(editor.getBlock(paragraphId)).not.toBeNull(); - - await unmountDatabase(root, container, editor); - }); - - it("supports multi-sort via shift-click on column headers", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-sort", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "tags", title: "Priority", type: "number", width: 120 }, - ], - [["A", "2"], ["B", "1"]], - ); - const { container, root } = await renderDatabase(editor); - - const nameHeader = container.querySelector( - `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="0"]`, - ) as HTMLElement | null; - const priorityHeader = container.querySelector( - `[data-block-id="db-sort"] [data-cell-row="0"][data-cell-col="1"]`, - ) as HTMLElement | null; - expect(nameHeader).not.toBeNull(); - expect(priorityHeader).not.toBeNull(); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "asc" }, - ]); - - await act(async () => { - priorityHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "asc" }, - { columnId: "tags", direction: "asc" }, - ]); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "desc" }, - { columnId: "tags", direction: "asc" }, - ]); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("click", { shiftKey: true })); - await flushAnimationFrames(1); - }); - expect(editor.getBlock("db-sort")?.databaseActiveView()?.sort).toEqual([ - { columnId: "tags", direction: "asc" }, - ]); - - await unmountDatabase(root, container, editor); - }); - - it("keeps column header controls out of editor selection gestures", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-header-controls", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "tags", title: "Priority", type: "number", width: 120 }, - ], - [["A", "2"], ["B", "1"]], - ); - const { container, root } = await renderDatabase(editor); - - const nameHeader = container.querySelector( - `[data-block-id="db-header-controls"] [data-cell-row="0"][data-cell-col="0"]`, - ) as HTMLElement | null; - const menuButton = container.querySelector( - `[data-block-id="db-header-controls"] .pen-db-col-menu-btn`, - ) as HTMLButtonElement | null; - expect(nameHeader).not.toBeNull(); - expect(menuButton).not.toBeNull(); - expect(editor.selection).toBeNull(); - - await act(async () => { - nameHeader?.dispatchEvent(createMouseEvent("mousedown")); - nameHeader?.dispatchEvent(createMouseEvent("mouseup")); - nameHeader?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-header-controls")?.databaseActiveView()?.sort).toEqual([ - { columnId: "name", direction: "asc" }, - ]); - expect(editor.selection).toBeNull(); - - await act(async () => { - menuButton?.dispatchEvent(createMouseEvent("mousedown")); - menuButton?.dispatchEvent(createMouseEvent("mouseup")); - menuButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const renameInput = container.querySelector( - `[data-block-id="db-header-controls"] .pen-db-col-rename-input`, - ) as HTMLInputElement | null; - expect(renameInput).not.toBeNull(); - - await act(async () => { - renameInput?.dispatchEvent(createMouseEvent("mousedown")); - renameInput?.dispatchEvent(createMouseEvent("mouseup")); - renameInput?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.selection).toBeNull(); - - await unmountDatabase(root, container, editor); - }); - - it("applies sticky left and right pin styles to pinned columns", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-pins", - [ - { id: "name", title: "Name", type: "text", width: 120, pinned: "left" }, - { id: "tags", title: "Status", type: "text", width: 120 }, - { id: "status", title: "Due", type: "text", width: 140, pinned: "right" }, - ], - [["A", "Open", "Soon"]], - ); - const { container, root } = await renderDatabase(editor); - - const leftHeader = container.querySelector( - `[data-block-id="db-pins"] th[data-cell-col="0"]`, - ) as HTMLTableCellElement | null; - const rightHeader = container.querySelector( - `[data-block-id="db-pins"] th[data-cell-col="2"]`, - ) as HTMLTableCellElement | null; - const leftCell = container.querySelector( - `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="0"]`, - ) as HTMLTableCellElement | null; - const rightCell = container.querySelector( - `[data-block-id="db-pins"] td[data-cell-row="0"][data-cell-col="2"]`, - ) as HTMLTableCellElement | null; - - expect(leftHeader?.style.position).toBe("sticky"); - expect(leftHeader?.style.left).toBe("44px"); - expect(rightHeader?.style.position).toBe("sticky"); - expect(rightHeader?.style.right).toBe("0px"); - expect(leftCell?.style.left).toBe("44px"); - expect(rightCell?.style.right).toBe("0px"); - - await unmountDatabase(root, container, editor); - }); - - it("shows facet-backed autocomplete options in the filter panel", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-filter", - [ - { - id: "status", - title: "Status", - type: "select", - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - ], - [["todo"], ["done"], ["todo"]], - ); - const { container, root } = await renderDatabase(editor); - - const filterButton = getButtonByText(container, "Filter"); - expect(filterButton).not.toBeNull(); - - await act(async () => { - filterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; - expect(addFilterButton).not.toBeNull(); - - await act(async () => { - addFilterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const datalist = container.querySelector('datalist[id="pen-db-filter-values-0"]'); - const todoOption = datalist?.querySelector('option[value="todo"]') as HTMLOptionElement | null; - const doneOption = datalist?.querySelector('option[value="done"]') as HTMLOptionElement | null; - expect(todoOption?.label).toBe("Todo (2)"); - expect(doneOption?.label).toBe("Done (1)"); - - await unmountDatabase(root, container, editor); - }); - - it("manages the multi-sort stack from the sort panel", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-sort-panel", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "tags", title: "Priority", type: "number", width: 120 }, - { id: "status", title: "Status", type: "text", width: 120 }, - ], - [["A", "2", "Open"], ["B", "1", "Done"]], - ); - const { container, root } = await renderDatabase(editor); - - const sortButton = getButtonByText(container, "Sort"); - expect(sortButton).not.toBeNull(); - - await act(async () => { - sortButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; - expect(addSortButton).not.toBeNull(); - - await act(async () => { - addSortButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const refreshedAddSortButton = container.querySelector(".pen-db-sort-add") as HTMLButtonElement | null; - expect(refreshedAddSortButton).not.toBeNull(); - - await act(async () => { - refreshedAddSortButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const secondColumnSelect = container.querySelector('[data-sort-column="1"]') as HTMLSelectElement | null; - expect(secondColumnSelect).not.toBeNull(); - - await act(async () => { - if (secondColumnSelect) { - secondColumnSelect.value = "tags"; - secondColumnSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(1); - }); - - const refreshedSecondDirectionSelect = container.querySelector( - '[data-sort-direction="1"]', - ) as HTMLSelectElement | null; - expect(refreshedSecondDirectionSelect).not.toBeNull(); - - await act(async () => { - if (refreshedSecondDirectionSelect) { - refreshedSecondDirectionSelect.value = "desc"; - refreshedSecondDirectionSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(1); - }); - - const moveUpButton = container.querySelector('[data-sort-move-up="1"]') as HTMLButtonElement | null; - expect(moveUpButton).not.toBeNull(); - - await act(async () => { - moveUpButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-sort-panel")?.databaseActiveView()?.sort).toEqual([ - { columnId: "tags", direction: "desc" }, - { columnId: "name", direction: "asc" }, - ]); - - await unmountDatabase(root, container, editor); - }); - - it("supports nested filter groups from the filter panel", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-filter-groups", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "status", title: "Status", type: "text", width: 120 }, - ], - [["Alpha", "Open"], ["Beta", "Done"]], - ); - const { container, root } = await renderDatabase(editor); - - const filterButton = getButtonByText(container, "Filter"); - expect(filterButton).not.toBeNull(); - - await act(async () => { - filterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addGroupButton = container.querySelector('[data-filter-add-group="root"]') as HTMLButtonElement | null; - expect(addGroupButton).not.toBeNull(); - - await act(async () => { - addGroupButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const nestedValueInput = container.querySelector('[data-filter-value="0-0"]') as HTMLInputElement | null; - expect(nestedValueInput).not.toBeNull(); - - await act(async () => { - if (nestedValueInput) { - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - valueSetter?.call(nestedValueInput, "Alpha"); - nestedValueInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Alpha" })); - } - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-filter-groups")?.databaseActiveView()?.filter).toEqual({ - operator: "and", - conditions: [ - { - operator: "and", - conditions: [ - { columnId: "name", operator: "contains", value: "Alpha" }, - ], - }, - ], - }); - - const renderedRows = Array.from( - container.querySelectorAll(`[data-block-id="db-filter-groups"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - expect(renderedRows).toHaveLength(1); - expect(renderedRows[0]?.textContent).toContain("Alpha"); - - await unmountDatabase(root, container, editor); - }); - - it("filters dates with relative presets from the filter panel", async () => { - const now = new Date(); - const recentDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2, 9, 0, 0); - const oldDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 12, 9, 0, 0); - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-date-filter", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-date-filter", - columnId: "tags", - patch: { - title: "Due", - }, - }, - { - type: "database-convert-column", - blockId: "db-date-filter", - columnId: "tags", - toType: "date", - }, - { - type: "database-insert-row", - blockId: "db-date-filter", - rowId: "row-a", - values: { name: "Alpha", tags: recentDate.toISOString() }, - }, - { - type: "database-insert-row", - blockId: "db-date-filter", - rowId: "row-b", - values: { name: "Beta", tags: oldDate.toISOString() }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const filterButton = getButtonByText(container, "Filter"); - expect(filterButton).not.toBeNull(); - - await act(async () => { - filterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addFilterButton = container.querySelector(".pen-db-filter-add") as HTMLButtonElement | null; - expect(addFilterButton).not.toBeNull(); - - await act(async () => { - addFilterButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const columnSelect = container.querySelector('[data-filter-column="0"]') as HTMLSelectElement | null; - expect(columnSelect).not.toBeNull(); - - await act(async () => { - if (columnSelect) { - columnSelect.value = "tags"; - columnSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(2); - }); - - const operatorSelect = container.querySelector('[data-filter-operator="0"]') as HTMLSelectElement | null; - expect(operatorSelect).not.toBeNull(); - expect( - Array.from(operatorSelect?.options ?? []).some( - (option) => option.value === "is_relative", - ), - ).toBe(true); - - updatePrimaryView(editor, "db-date-filter", { - filter: { - operator: "and", - conditions: [{ - columnId: "tags", - operator: "is_relative", - value: "last_7_days", - }], - }, - }); - - await act(async () => { - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-date-filter")?.databaseActiveView()?.filter).toEqual({ - operator: "and", - conditions: [{ - columnId: "tags", - operator: "is_relative", - value: "last_7_days", - }], - }); - - await unmountDatabase(root, container, editor); - }); - - it("pins selected rows to the top and bottom through the toolbar", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-row-pins", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-insert-row", - blockId: "db-row-pins", - rowId: "row-a", - values: { name: "Alpha" }, - }, - { - type: "database-insert-row", - blockId: "db-row-pins", - rowId: "row-b", - values: { name: "Beta" }, - }, - { - type: "database-insert-row", - blockId: "db-row-pins", - rowId: "row-c", - values: { name: "Gamma" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const rowCheckboxes = Array.from( - container.querySelectorAll( - `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, - ), - ) as HTMLInputElement[]; - expect(rowCheckboxes).toHaveLength(3); - - await act(async () => { - rowCheckboxes[1]?.click(); - await flushAnimationFrames(1); - }); - - const pinTopButton = getButtonByText(container, "Pin top"); - expect(pinTopButton).not.toBeNull(); - - await act(async () => { - pinTopButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ - top: ["row-b"], - }); - - let renderedRows = Array.from( - container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - expect(renderedRows[0]?.getAttribute("data-row-section")).toBe("top"); - expect(renderedRows[0]?.textContent).toContain("Beta"); - - const refreshedRowCheckboxes = Array.from( - container.querySelectorAll( - `[data-block-id="db-row-pins"] tbody tr[data-row] .pen-db-row-select-cell input`, - ), - ) as HTMLInputElement[]; - - await act(async () => { - refreshedRowCheckboxes[0]?.click(); - await flushAnimationFrames(1); - }); - - await act(async () => { - refreshedRowCheckboxes[2]?.click(); - await flushAnimationFrames(1); - }); - - const pinBottomButton = getButtonByText(container, "Pin bottom"); - expect(pinBottomButton).not.toBeNull(); - - await act(async () => { - pinBottomButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-row-pins")?.databaseActiveView()?.rowPinning).toEqual({ - top: ["row-b"], - bottom: ["row-c"], - }); - - renderedRows = Array.from( - container.querySelectorAll(`[data-block-id="db-row-pins"] tbody tr[data-row]`), - ) as HTMLTableRowElement[]; - expect(renderedRows.at(-1)?.getAttribute("data-row-section")).toBe("bottom"); - expect(renderedRows.at(-1)?.textContent).toContain("Gamma"); - - await unmountDatabase(root, container, editor); - }); - - it("refreshes the open column menu after adding a select option", async () => { - const editor = createEditor({ - }); - - function OptionMutationHarness() { - const db = useDatabaseController({ blockId: "db-option-menu" }); - const statusColumn = db.columnSchema.find((entry) => entry.id === "status"); - return ( - <> - - { }} - onRename={(nextTitle) => db.renameColumn("status", nextTitle)} - onChangeType={(nextType) => db.changeColumnType("status", nextType)} - onDelete={() => db.deleteColumn("status")} - onToggleVisibility={() => db.toggleColumnVisibility("status")} - onChangePin={(nextPinned) => db.changeColumnPin("status", nextPinned)} - onAddOption={(value, color) => db.addOption("status", value, color)} - onRenameOption={(optionId, value) => db.renameOption("status", optionId, value)} - onRecolorOption={(optionId, color) => db.recolorOption("status", optionId, color)} - onRemoveOption={(optionId) => db.removeOption("status", optionId)} - onMoveOption={(optionId, direction) => db.moveOption("status", optionId, direction)} - /> - - ); - } - - seedDatabase( - editor, - "db-option-menu", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "status", title: "Status", type: "select", width: 140, options: [] }, - ], - [["Alpha", ""]], - ); - const { container, root } = await renderDatabase( - editor, - { children: }, - ); - - let optionRows = Array.from( - container.querySelectorAll(`.pen-db-col-option-row input`), - ) as HTMLInputElement[]; - expect(optionRows).toHaveLength(0); - - const addOptionButton = getButtonByText(container, "Add test option"); - expect(addOptionButton).not.toBeNull(); - - await act(async () => { - addOptionButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-option-menu")?.tableColumns()[1]?.options).toEqual([ - expect.objectContaining({ - value: "Blocked", - color: "gray", - }), - ]); - - optionRows = Array.from( - container.querySelectorAll(`.pen-db-col-option-row input`), - ) as HTMLInputElement[]; - expect(optionRows).toHaveLength(1); - expect(optionRows[0]?.value).toBe("Blocked"); - - await unmountDatabase(root, container, editor); - }); - - it("renders grouped sections from the group panel", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-group", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-group", - columnId: "tags", - patch: { - title: "Status", - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - }, - { - type: "database-insert-row", - blockId: "db-group", - rowId: "row-a", - values: { name: "Alpha", tags: "todo" }, - }, - { - type: "database-insert-row", - blockId: "db-group", - rowId: "row-b", - values: { name: "Beta", tags: "done" }, - }, - { - type: "database-insert-row", - blockId: "db-group", - rowId: "row-c", - values: { name: "Gamma", tags: "todo" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const groupButton = getButtonByText(container, "Group"); - expect(groupButton).not.toBeNull(); - - await act(async () => { - groupButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const groupSelect = container.querySelector(".pen-db-col-vis-panel select") as HTMLSelectElement | null; - expect(groupSelect).not.toBeNull(); - - await act(async () => { - if (groupSelect) { - groupSelect.value = "tags"; - groupSelect.dispatchEvent(new Event("change", { bubbles: true })); - } - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-group")?.databaseActiveView()?.groupBy).toBe("tags"); - - const groupRows = Array.from( - container.querySelectorAll(`[data-block-id="db-group"] .pen-db-group-row`), - ) as HTMLTableRowElement[]; - expect(groupRows).toHaveLength(2); - expect(groupRows[0]?.textContent).toContain("Todo (2)"); - expect(groupRows[1]?.textContent).toContain("Done (1)"); - - await unmountDatabase(root, container, editor); - }); - - it("adds switches and removes database views from the title bar", async () => { - const editor = createEditor({ - }); - seedDatabase( - editor, - "db-views", - [ - { id: "name", title: "Name", type: "text", width: 140 }, - { id: "status", title: "Status", type: "text", width: 120 }, - ], - [["Alpha", "Open"], ["Beta", "Done"]], - ); - const primaryViewId = editor.getBlock("db-views")?.databasePrimaryViewId() ?? ""; - const { container, root } = await renderDatabase(editor); - - const addViewButton = getButtonByText(container, "+ View"); - expect(addViewButton).not.toBeNull(); - - await act(async () => { - addViewButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const addListViewButton = getButtonByText(container, "New list view"); - const addBoardViewButton = getButtonByText(container, "New board view"); - const addCalendarViewButton = getButtonByText(container, "New calendar view"); - const addGalleryViewButton = getButtonByText(container, "New gallery view"); - expect(addListViewButton).not.toBeNull(); - expect(addBoardViewButton).not.toBeNull(); - expect(addCalendarViewButton).not.toBeNull(); - expect(addGalleryViewButton).not.toBeNull(); - - await act(async () => { - addListViewButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - const blockAfterAdd = editor.getBlock("db-views"); - const listView = blockAfterAdd?.databaseViews().find((view) => view.type === "list"); - expect(listView).toBeDefined(); - expect(blockAfterAdd?.databaseViews()).toHaveLength(2); - expect(blockAfterAdd?.databaseActiveView()?.id).toBe(listView?.id); - expect(container.querySelector(`[data-block-id="db-views"] .pen-db-list-view`)).not.toBeNull(); - - const tableTab = container.querySelector( - `[data-block-id="db-views"] [data-view-id="${primaryViewId}"]`, - ) as HTMLButtonElement | null; - expect(tableTab).not.toBeNull(); - - await act(async () => { - tableTab?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(2); - }); - - expect(editor.getBlock("db-views")?.databaseActiveView()?.id).toBe(primaryViewId); - expect(container.querySelector(`[data-block-id="db-views"] table[data-pen-table]`)).not.toBeNull(); - - const removeListViewButton = container.querySelector( - `[data-block-id="db-views"] [data-remove-view-id="${listView?.id ?? ""}"]`, - ) as HTMLButtonElement | null; - expect(removeListViewButton).not.toBeNull(); - - await act(async () => { - removeListViewButton?.dispatchEvent(createMouseEvent("click")); - await flushAnimationFrames(1); - }); - - expect(editor.getBlock("db-views")?.databaseViews()).toHaveLength(1); - expect(editor.getBlock("db-views")?.databasePrimaryViewId()).toBe(primaryViewId); - - await unmountDatabase(root, container, editor); - }); - - it("renders list views as stacked row cards", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-list", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-add-view", - blockId: "db-list", - view: { - id: "view-list", - title: "List view", - type: "list", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: null, - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-list", - viewId: "view-list", - }, - { - type: "database-insert-row", - blockId: "db-list", - rowId: "row-a", - values: { name: "Alpha", tags: "Todo", status: "true" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const listView = container.querySelector( - `[data-block-id="db-list"] .pen-db-list-view`, - ) as HTMLDivElement | null; - expect(listView).not.toBeNull(); - expect(container.querySelector(`[data-block-id="db-list"] table[data-pen-table]`)).toBeNull(); - - const listRow = container.querySelector( - `[data-block-id="db-list"] .pen-db-list-row[data-row="0"]`, - ) as HTMLDivElement | null; - expect(listRow).not.toBeNull(); - expect(listRow?.textContent).toContain("Name"); - expect(listRow?.textContent).toContain("Tags"); - expect(listRow?.textContent).toContain("Done"); - expect(listRow?.textContent).toContain("Alpha"); - - await unmountDatabase(root, container, editor); - }); - - it("renders board views as grouped kanban lanes", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-board", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-board", - columnId: "tags", - patch: { - title: "Status", - options: [ - { id: "todo", value: "Todo" }, - { id: "done", value: "Done" }, - ], - }, - }, - { - type: "database-add-view", - blockId: "db-board", - view: { - id: "view-board", - title: "Board view", - type: "board", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: "tags", - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-board", - viewId: "view-board", - }, - { - type: "database-insert-row", - blockId: "db-board", - rowId: "row-a", - values: { name: "Alpha", tags: "todo", status: "true" }, - }, - { - type: "database-insert-row", - blockId: "db-board", - rowId: "row-b", - values: { name: "Beta", tags: "done", status: "false" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const boardView = container.querySelector( - `[data-block-id="db-board"] .pen-db-board-view`, - ) as HTMLDivElement | null; - expect(boardView).not.toBeNull(); - - const laneHeaders = Array.from( - container.querySelectorAll(`[data-block-id="db-board"] .pen-db-board-lane-header`), - ) as HTMLDivElement[]; - expect(laneHeaders).toHaveLength(2); - expect(laneHeaders[0]?.textContent).toContain("Todo (1)"); - expect(laneHeaders[1]?.textContent).toContain("Done (1)"); - - const boardCard = container.querySelector( - `[data-block-id="db-board"] .pen-db-board-card[data-row="0"]`, - ) as HTMLDivElement | null; - expect(boardCard).not.toBeNull(); - expect(boardCard?.textContent).toContain("Alpha"); - expect(boardCard?.textContent).toContain("Status"); - - await unmountDatabase(root, container, editor); - }); - - it("renders gallery views as row cards", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-gallery", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-add-view", - blockId: "db-gallery", - view: { - id: "view-gallery", - title: "Gallery view", - type: "gallery", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: null, - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-gallery", - viewId: "view-gallery", - }, - { - type: "database-insert-row", - blockId: "db-gallery", - rowId: "row-a", - values: { name: "Alpha", tags: "Todo", status: "true" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - const galleryView = container.querySelector( - `[data-block-id="db-gallery"] .pen-db-gallery-view`, - ) as HTMLDivElement | null; - expect(galleryView).not.toBeNull(); - - const galleryCard = container.querySelector( - `[data-block-id="db-gallery"] .pen-db-gallery-card[data-row="0"]`, - ) as HTMLDivElement | null; - expect(galleryCard).not.toBeNull(); - expect(galleryCard?.textContent).toContain("Name"); - expect(galleryCard?.textContent).toContain("Alpha"); - expect(galleryCard?.textContent).toContain("Tags"); - - await unmountDatabase(root, container, editor); - }); - - it("renders calendar views from the first date column", async () => { - const editor = createEditor({ - }); - editor.apply([ - { - type: "insert-block", - blockId: "db-calendar", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "database-update-column", - blockId: "db-calendar", - columnId: "tags", - patch: { - title: "Due", - }, - }, - { - type: "database-convert-column", - blockId: "db-calendar", - columnId: "tags", - toType: "date", - }, - { - type: "database-add-view", - blockId: "db-calendar", - view: { - id: "view-calendar", - title: "Calendar view", - type: "calendar", - visibleColumnIds: ["name", "tags", "status"], - columnOrder: ["name", "tags", "status"], - sort: [], - filter: null, - groupBy: null, - pageIndex: 0, - pageSize: 50, - }, - }, - { - type: "database-set-active-view", - blockId: "db-calendar", - viewId: "view-calendar", - }, - { - type: "database-insert-row", - blockId: "db-calendar", - rowId: "row-a", - values: { name: "Alpha", tags: "2024-03-10T09:00:00.000Z", status: "true" }, - }, - { - type: "database-insert-row", - blockId: "db-calendar", - rowId: "row-b", - values: { name: "Beta", tags: "", status: "false" }, - }, - ]); - const { container, root } = await renderDatabase(editor); - - await act(async () => { - await flushAnimationFrames(2); - }); - - const calendarView = container.querySelector( - `[data-block-id="db-calendar"] .pen-db-calendar-view`, - ) as HTMLDivElement | null; - expect(calendarView).not.toBeNull(); - - const calendarCards = Array.from( - container.querySelectorAll( - `[data-block-id="db-calendar"] .pen-db-calendar-view .pen-db-calendar-card`, - ), - ) as HTMLDivElement[]; - expect( - calendarCards.some((card) => card.textContent?.includes("Alpha")), - ).toBe(true); - - const unscheduledSection = container.querySelector( - `[data-block-id="db-calendar"] .pen-db-calendar-unscheduled`, - ) as HTMLDivElement | null; - expect(unscheduledSection).not.toBeNull(); - expect(unscheduledSection?.textContent).toContain("Beta"); - - await unmountDatabase(root, container, editor); - }); }); diff --git a/packages/extensions/database/src/cellEditorSpecializedCells.tsx b/packages/extensions/database/src/cellEditorSpecializedCells.tsx new file mode 100644 index 0000000..37efdbe --- /dev/null +++ b/packages/extensions/database/src/cellEditorSpecializedCells.tsx @@ -0,0 +1,130 @@ +import { useCellTextSnapshot, useEditorContext } from "@pen/react"; +import { useState } from "react"; +import type { DatabaseCellContentProps } from "./cellEditors"; +import { setCellText, widgetCellAttrs } from "./cellEditorUtils"; + +export function RelationCell(props: DatabaseCellContentProps) { + const { blockId, row, col } = props; + const { editor } = useEditorContext(); + const readonly = !!props.readonly; + const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); + const currentValue = textSnapshot.text ?? ""; + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(currentValue); + + function handleSave() { + setCellText(editor, blockId, row, col, draftValue.trim()); + setIsEditing(false); + } + + if (!isEditing) { + return ( + { + if (readonly) return; + event.stopPropagation(); + setDraftValue(currentValue); + setIsEditing(true); + }} + > + {currentValue ? ( + {currentValue} + ) : ( + Link record… + )} + + ); + } + + return ( + + setDraftValue(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleSave(); + } + if (event.key === "Escape") { + event.preventDefault(); + setIsEditing(false); + } + }} + autoFocus + /> + + + + ); +} + +export function FormulaCell(props: DatabaseCellContentProps) { + const { blockId, row, col } = props; + const { editor } = useEditorContext(); + const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); + const currentValue = textSnapshot.text ?? ""; + + return ( + + {currentValue || Computed value} + + ); +} + +export function DateCell(props: DatabaseCellContentProps) { + const { blockId, row, col, column } = props; + const { editor } = useEditorContext(); + const readonly = !!props.readonly; + const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); + const raw = textSnapshot.text ?? ""; + + let display = ""; + if (raw) { + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) { + const fmt = column.format as { includeTime?: boolean; dateStyle?: "short" | "medium" | "long" } | undefined; + const opts: Intl.DateTimeFormatOptions = { dateStyle: fmt?.dateStyle ?? "medium" }; + if (fmt?.includeTime) opts.timeStyle = "short"; + display = new Intl.DateTimeFormat(undefined, opts).format(d); + } else { + display = raw; + } + } + + function handleDateChange(event: React.ChangeEvent) { + if (readonly) return; + setCellText(editor, blockId, row, col, event.target.value ? new Date(event.target.value).toISOString() : ""); + } + + return ( + + {display || Pick date…} + {!readonly && ( + + )} + + ); +} diff --git a/packages/extensions/database/src/cellEditorUtils.ts b/packages/extensions/database/src/cellEditorUtils.ts new file mode 100644 index 0000000..f38a6d2 --- /dev/null +++ b/packages/extensions/database/src/cellEditorUtils.ts @@ -0,0 +1,82 @@ +import { DATA_ATTRS } from "@pen/react"; +import type { Editor } from "@pen/types"; + +export function setCellText(editor: Editor, blockId: string, row: number, col: number, text: string): void { + const block = editor.getBlock(blockId); + if (!block) return; + const rowHandle = block.tableRow(row); + const column = block.tableColumns()[col]; + const cell = block.tableCell(row, col); + if (!cell || !rowHandle || !column) return; + editor.apply([{ + type: "database-update-cell", + blockId, + rowId: rowHandle.id, + columnId: column.id, + value: text, + }], { origin: "user" }); +} + +export function toggleCheckbox(editor: Editor, blockId: string, row: number, col: number, isChecked: boolean): void { + setCellText(editor, blockId, row, col, isChecked ? "false" : "true"); +} + +export function isCellActive( + fieldEditorState: { activeCellCoord: { blockId: string; row: number; col: number } | null }, + blockId: string, + row: number, + col: number, +): boolean { + return ( + fieldEditorState.activeCellCoord?.blockId === blockId && + fieldEditorState.activeCellCoord.row === row && + fieldEditorState.activeCellCoord.col === col + ); +} + +export function editableCellAttrs( + isActive: boolean, + row: number, + col: number, + showPlaceholder: boolean, + placeholder?: string, +): Record { + return { + [DATA_ATTRS.inlineContent]: "", + [DATA_ATTRS.fieldEditorSurface]: "", + [DATA_ATTRS.fieldEditorActiveSurface]: isActive ? "" : undefined, + [DATA_ATTRS.ignorePointerGesture]: isActive ? "" : undefined, + [DATA_ATTRS.tableCellRow]: row, + [DATA_ATTRS.tableCellCol]: col, + [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, + "data-placeholder": showPlaceholder ? placeholder : undefined, + style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%" }, + }; +} + +export function widgetCellAttrs(row: number, col: number): Record { + return { + [DATA_ATTRS.ignorePointerGesture]: "", + [DATA_ATTRS.tableCellRow]: row, + [DATA_ATTRS.tableCellCol]: col, + style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%", cursor: "default" }, + }; +} + +const TAG_COLORS: Record = { + red: "rgba(255, 86, 86, 0.2)", + orange: "rgba(255, 163, 68, 0.2)", + yellow: "rgba(255, 220, 73, 0.2)", + green: "rgba(77, 208, 89, 0.2)", + blue: "rgba(45, 120, 255, 0.2)", + purple: "rgba(155, 89, 255, 0.2)", + pink: "rgba(255, 89, 166, 0.2)", + gray: "rgba(155, 155, 155, 0.2)", +}; + +export function tagColor(color?: string): string | undefined { + if (!color) { + return undefined; + } + return TAG_COLORS[color] ?? color; +} diff --git a/packages/extensions/database/src/cellEditors.tsx b/packages/extensions/database/src/cellEditors.tsx index 1d66b66..1a686a8 100644 --- a/packages/extensions/database/src/cellEditors.tsx +++ b/packages/extensions/database/src/cellEditors.tsx @@ -1,5 +1,4 @@ import React, { useRef, useLayoutEffect, useState } from "react"; -import type { Editor } from "@pen/types"; import { normalizeStoredMultiSelectValue, normalizeStoredSelectValue, @@ -17,6 +16,15 @@ import type { ColumnType, DatabaseColumnDef, SelectOption } from "./types"; import { isContentEditableColumnType } from "./types"; import type { CellEditorRegistry } from "./cellEditorRegistry"; +import { + editableCellAttrs, + isCellActive, + setCellText, + tagColor, + toggleCheckbox, + widgetCellAttrs, +} from "./cellEditorUtils"; +import { DateCell, FormulaCell, RelationCell } from "./cellEditorSpecializedCells"; export const DATABASE_CELL_EDITOR_REGISTRY_SLOT = "database:cell-editor-registry"; export interface DatabaseCellContentProps { @@ -413,208 +421,4 @@ function MultiSelectCell(props: DatabaseCellContentProps) { ); } -function RelationCell(props: DatabaseCellContentProps) { - const { blockId, row, col } = props; - const { editor } = useEditorContext(); - const readonly = !!props.readonly; - const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); - const currentValue = textSnapshot.text ?? ""; - const [isEditing, setIsEditing] = useState(false); - const [draftValue, setDraftValue] = useState(currentValue); - - function handleSave() { - setCellText(editor, blockId, row, col, draftValue.trim()); - setIsEditing(false); - } - if (!isEditing) { - return ( - { - if (readonly) return; - event.stopPropagation(); - setDraftValue(currentValue); - setIsEditing(true); - }} - > - {currentValue ? ( - {currentValue} - ) : ( - Link record… - )} - - ); - } - - return ( - - setDraftValue(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - handleSave(); - } - if (event.key === "Escape") { - event.preventDefault(); - setIsEditing(false); - } - }} - autoFocus - /> - - - - ); -} - -function FormulaCell(props: DatabaseCellContentProps) { - const { blockId, row, col } = props; - const { editor } = useEditorContext(); - const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); - const currentValue = textSnapshot.text ?? ""; - - return ( - - {currentValue || Computed value} - - ); -} - -function DateCell(props: DatabaseCellContentProps) { - const { blockId, row, col, column } = props; - const { editor } = useEditorContext(); - const readonly = !!props.readonly; - const textSnapshot = useCellTextSnapshot(editor, blockId, row, col); - const raw = textSnapshot.text ?? ""; - - let display = ""; - if (raw) { - const d = new Date(raw); - if (!Number.isNaN(d.getTime())) { - const fmt = column.format as { includeTime?: boolean; dateStyle?: "short" | "medium" | "long" } | undefined; - const opts: Intl.DateTimeFormatOptions = { dateStyle: fmt?.dateStyle ?? "medium" }; - if (fmt?.includeTime) opts.timeStyle = "short"; - display = new Intl.DateTimeFormat(undefined, opts).format(d); - } else { - display = raw; - } - } - - function handleDateChange(event: React.ChangeEvent) { - if (readonly) return; - setCellText(editor, blockId, row, col, event.target.value ? new Date(event.target.value).toISOString() : ""); - } - - return ( - - {display || Pick date…} - {!readonly && ( - - )} - - ); -} - -function setCellText(editor: Editor, blockId: string, row: number, col: number, text: string): void { - const block = editor.getBlock(blockId); - if (!block) return; - const rowHandle = block.tableRow(row); - const column = block.tableColumns()[col]; - const cell = block.tableCell(row, col); - if (!cell || !rowHandle || !column) return; - editor.apply([{ - type: "database-update-cell", - blockId, - rowId: rowHandle.id, - columnId: column.id, - value: text, - }], { origin: "user" }); -} - -function toggleCheckbox(editor: Editor, blockId: string, row: number, col: number, isChecked: boolean): void { - setCellText(editor, blockId, row, col, isChecked ? "false" : "true"); -} - -function isCellActive( - fieldEditorState: { activeCellCoord: { blockId: string; row: number; col: number } | null }, - blockId: string, - row: number, - col: number, -): boolean { - return ( - fieldEditorState.activeCellCoord?.blockId === blockId && - fieldEditorState.activeCellCoord.row === row && - fieldEditorState.activeCellCoord.col === col - ); -} - -function editableCellAttrs( - isActive: boolean, - row: number, - col: number, - showPlaceholder: boolean, - placeholder?: string, -): Record { - return { - [DATA_ATTRS.inlineContent]: "", - [DATA_ATTRS.fieldEditorSurface]: "", - [DATA_ATTRS.fieldEditorActiveSurface]: isActive ? "" : undefined, - [DATA_ATTRS.ignorePointerGesture]: isActive ? "" : undefined, - [DATA_ATTRS.tableCellRow]: row, - [DATA_ATTRS.tableCellCol]: col, - [DATA_ATTRS.placeholderVisible]: showPlaceholder ? "" : undefined, - "data-placeholder": showPlaceholder ? placeholder : undefined, - style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%" }, - }; -} - -function widgetCellAttrs(row: number, col: number): Record { - return { - [DATA_ATTRS.ignorePointerGesture]: "", - [DATA_ATTRS.tableCellRow]: row, - [DATA_ATTRS.tableCellCol]: col, - style: { minWidth: "4rem", minHeight: "1.5rem", display: "block", width: "100%", cursor: "default" }, - }; -} - -const TAG_COLORS: Record = { - red: "rgba(255, 86, 86, 0.2)", - orange: "rgba(255, 163, 68, 0.2)", - yellow: "rgba(255, 220, 73, 0.2)", - green: "rgba(77, 208, 89, 0.2)", - blue: "rgba(45, 120, 255, 0.2)", - purple: "rgba(155, 89, 255, 0.2)", - pink: "rgba(255, 89, 166, 0.2)", - gray: "rgba(155, 155, 155, 0.2)", -}; - -function tagColor(color?: string): string | undefined { - if (!color) { - return undefined; - } - return TAG_COLORS[color] ?? color; -} diff --git a/packages/extensions/database/src/databaseControllerMutationHandlers.ts b/packages/extensions/database/src/databaseControllerMutationHandlers.ts new file mode 100644 index 0000000..b394cb3 --- /dev/null +++ b/packages/extensions/database/src/databaseControllerMutationHandlers.ts @@ -0,0 +1,382 @@ +import type React from "react"; +import { generateId } from "@pen/types"; +import type { ColumnType, DatabaseColumnDef, DatabaseViewModelColumn, DatabaseViewModelRow, DatabaseViewState, FilterGroup } from "./types"; +import { isCellInSelection } from "./utils"; +import { + createDatabaseViewDefinition, + getNextSortState, + shiftMonth, +} from "./utils/databaseRenderer"; + +type MutationHandlerContext = Record; + +export function createDatabaseMutationHandlers(context: MutationHandlerContext) { + const { + activeCalendarMonth, + allRows, + block, + blockId, + calendarMonth, + cellSelection, + columnSchema, + columns, + databaseViews, + editor, + engine, + globalSearch, + isDataReadonly, + isUiReadonly, + pageCount, + rowSelection, + setActiveColumnMenu, + setCalendarMonth, + setColumnSchemaRefreshToken, + setGlobalSearchRaw, + setIsEditingTitle, + setShowAddViewMenu, + setViewState, + title, + viewState, + visibleColumnIds, + visibleColumnIdSet, + visibleRows, + } = context; + + function updateViewState(patch: Partial>) { + const nextView = { + ...viewState, + ...patch, + }; + setViewState(nextView); + editor.apply([ + { + type: "database-update-view", + blockId, + viewId: block.databasePrimaryViewId() ?? undefined, + patch, + }, + ], { origin: "user" }); + } + + function handleTitleClick() { + if (isUiReadonly) return; + setIsEditingTitle(true); + } + + function handleTitleBlur(event: React.FocusEvent) { + setIsEditingTitle(false); + const nextTitle = event.currentTarget.value.trim() || "Untitled"; + if (nextTitle === title) return; + editor.apply([ + { + type: "update-block", + blockId, + props: { title: nextTitle }, + }, + ]); + } + + function handleTitleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter" || event.key === "Escape") { + event.currentTarget.blur(); + } + } + function handleHeaderClick(event: React.MouseEvent, columnId: string) { + const nextSort = getNextSortState(viewState.sort ?? [], columnId, event.shiftKey); + updateViewState({ sort: nextSort, pageIndex: 0 }); + } + + function handleAddRow() { + if (isDataReadonly) return; + editor.apply([ + { + type: "database-insert-row", + blockId, + index: block.tableRowCount(), + }, + ], { origin: "user" }); + } + + function handleAddColumn() { + if (isUiReadonly) return; + const columnId = generateId(); + const nextColumn: DatabaseColumnDef = { + id: columnId, + title: "New column", + type: "text", + }; + editor.apply([ + { + type: "database-add-column", + blockId, + column: nextColumn, + index: block.tableColumnCount(), + viewId: block.databasePrimaryViewId() ?? undefined, + }, + ], { origin: "user" }); + } + + function handleAddView(nextType: DatabaseViewState["type"]) { + if (isUiReadonly) return; + const nextViewId = generateId(); + const nextView = createDatabaseViewDefinition({ + id: nextViewId, + type: nextType, + columns: columnSchema, + existingViews: databaseViews, + }); + setViewState(nextView); + editor.apply([ + { + type: "database-add-view", + blockId, + view: nextView, + }, + { + type: "database-set-active-view", + blockId, + viewId: nextViewId, + }, + ], { origin: "user" }); + setShowAddViewMenu(false); + } + + function handleSetActiveView(viewId: string) { + const nextView = databaseViews.find((view: DatabaseViewState) => view.id === viewId); + if (nextView) { + setViewState(nextView); + } + editor.apply([ + { + type: "database-set-active-view", + blockId, + viewId, + }, + ], { origin: "user" }); + } + + function handleRemoveView(viewId: string) { + if (isUiReadonly || databaseViews.length <= 1) return; + const currentActiveViewId = block.databasePrimaryViewId() ?? viewState.id; + if (currentActiveViewId === viewId) { + const fallbackView = databaseViews.find((view: DatabaseViewState) => view.id !== viewId); + if (fallbackView) { + setViewState(fallbackView); + } + } + editor.apply([ + { + type: "database-remove-view", + blockId, + viewId, + }, + ], { origin: "user" }); + } + function handleDeleteColumn(columnId: string) { + if (isUiReadonly) return; + editor.apply([ + { type: "database-remove-column", blockId, columnId }, + ], { origin: "user" }); + setActiveColumnMenu(null); + } + + function handleRenameColumn(columnId: string, nextTitle: string) { + editor.apply([{ + type: "database-update-column", + blockId, + columnId, + patch: { title: nextTitle || "Untitled" }, + }], { origin: "user" }); + setActiveColumnMenu(null); + } + + function handleChangeColumnType(columnId: string, nextType: ColumnType) { + const targetColumn = columnSchema.find((column: DatabaseColumnDef) => column.id === columnId); + if (!targetColumn || targetColumn.type === nextType) return; + editor.apply([{ + type: "database-convert-column", + blockId, + columnId, + toType: nextType, + }], { origin: "user" }); + setActiveColumnMenu(null); + } + + function handleToggleColumnVisibility(columnId: string) { + const nextVisibleColumnIds = visibleColumnIdSet.has(columnId) + ? visibleColumnIds.filter((id: string) => id !== columnId) + : [...visibleColumnIds, columnId]; + updateViewState({ visibleColumnIds: nextVisibleColumnIds }); + } + + function handleChangeColumnPin( + columnId: string, + nextPinned: "left" | "right" | undefined, + ) { + editor.apply([{ + type: "database-update-column", + blockId, + columnId, + patch: { pinned: nextPinned }, + }], { origin: "user" }); + setActiveColumnMenu(null); + } + + function refreshColumnSchemaSoon() { + requestAnimationFrame(() => { + setColumnSchemaRefreshToken((value: number) => value + 1); + }); + } + + function handleAddOption(columnId: string, value: string, color?: string) { + const trimmedValue = value.trim(); + if (!trimmedValue) return; + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "add", + option: { + id: generateId(), + value: trimmedValue, + color, + }, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleRenameOption(columnId: string, optionId: string, value: string) { + const trimmedValue = value.trim(); + if (!trimmedValue) return; + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "rename", + optionId, + value: trimmedValue, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleRecolorOption(columnId: string, optionId: string, color: string) { + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "recolor", + optionId, + color, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleRemoveOption(columnId: string, optionId: string) { + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "remove", + optionId, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleMoveOption(columnId: string, optionId: string, direction: "up" | "down") { + const column = columnSchema.find((entry: DatabaseColumnDef) => entry.id === columnId); + const currentOptions = column?.options ?? []; + const currentIndex = currentOptions.findIndex((option: NonNullable[number]) => option.id === optionId); + if (currentIndex < 0) return; + const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; + if (targetIndex < 0 || targetIndex >= currentOptions.length) return; + const nextOrder = [...currentOptions.map((option: NonNullable[number]) => option.id)]; + const [movedOptionId] = nextOrder.splice(currentIndex, 1); + nextOrder.splice(targetIndex, 0, movedOptionId); + editor.apply([{ + type: "database-update-select-options", + blockId, + columnId, + action: "reorder", + order: nextOrder, + }], { origin: "user" }); + refreshColumnSchemaSoon(); + } + + function handleFilterGroupChange(nextFilter: FilterGroup | null) { + updateViewState({ filter: nextFilter, pageIndex: 0 }); + } + + function handleSortChange(nextSort: NonNullable) { + updateViewState({ sort: nextSort, pageIndex: 0 }); + } + + function handleChangeGroupBy(nextGroupBy: string | null) { + updateViewState({ groupBy: nextGroupBy, pageIndex: 0 }); + } + + function handlePreviousPage() { + updateViewState({ pageIndex: Math.max(0, (viewState.pageIndex ?? 0) - 1) }); + } + + function handleNextPage() { + updateViewState({ pageIndex: Math.min(pageCount - 1, (viewState.pageIndex ?? 0) + 1) }); + } + + function setGlobalSearch(value: string) { + setGlobalSearchRaw(value); + updateViewState({ pageIndex: 0 }); + } + function isCellSelectedFn(row: number, column: number): boolean { + return !!( + cellSelection && + isCellInSelection(cellSelection, row, column, { + rowId: visibleRows.find((entry: DatabaseViewModelRow) => entry.crdtRowIndex === row)?.id, + columnId: columns.find((entry: DatabaseViewModelColumn) => entry.columnIndex === column)?.id, + }) + ); + } + + function formatRemoteCell(row: DatabaseViewModelRow, column: DatabaseViewModelColumn): string { + return engine.formatCellDisplay( + row.cells[column.id] ?? "", + column.type, + column.format, + column.options, + ); + } + function shiftCalendarMonthFn(amount: number) { + setCalendarMonth(shiftMonth(activeCalendarMonth, amount)); + } + + return { + updateViewState, + handleTitleClick, + handleTitleBlur, + handleTitleKeyDown, + handleHeaderClick, + handleAddRow, + handleAddColumn, + handleAddView, + handleSetActiveView, + handleRemoveView, + handleDeleteColumn, + handleRenameColumn, + handleChangeColumnType, + handleToggleColumnVisibility, + handleChangeColumnPin, + handleAddOption, + handleRenameOption, + handleRecolorOption, + handleRemoveOption, + handleMoveOption, + handleFilterGroupChange, + handleSortChange, + handleChangeGroupBy, + handlePreviousPage, + handleNextPage, + setGlobalSearch, + isCellSelectedFn, + formatRemoteCell, + shiftCalendarMonthFn, + }; +} diff --git a/packages/extensions/database/src/databaseControllerSelectionHandlers.ts b/packages/extensions/database/src/databaseControllerSelectionHandlers.ts new file mode 100644 index 0000000..714e3d8 --- /dev/null +++ b/packages/extensions/database/src/databaseControllerSelectionHandlers.ts @@ -0,0 +1,282 @@ +import type React from "react"; +import { DATA_ATTRS } from "@pen/react"; +import type { CellSelection } from "@pen/types"; +import type { DatabaseViewModelColumn, DatabaseViewModelRow } from "./types"; +import { getNextRowPinningState } from "./utils/databaseRenderer"; + +type SelectionHandlerContext = Record; + +export function createDatabaseSelectionHandlers(context: SelectionHandlerContext) { + const { + allRows, + allVisibleSelected, + blockId, + cellSelection, + columns, + editor, + fieldEditor, + fieldEditorActiveCell, + isDataReadonly, + rowSelection, + setRowSelection, + updateViewState, + viewState, + visibleRowIds, + visibleRows, + visibleSelectionColumnIds, + } = context; + + function createDatabaseCellSelection( + anchor: { row: number; col: number }, + head: { row: number; col: number } = anchor, + ): CellSelection { + return { + type: "cell", + blockId, + anchor, + head, + rowIds: visibleRowIds, + columnIds: visibleSelectionColumnIds, + }; + } + + function findVisibleCellCoordByIds( + rowId: string | null, + columnId: string | null, + ): { row: number; col: number } | null { + if (!rowId || !columnId) { + return null; + } + const row = visibleRows.findIndex((entry: DatabaseViewModelRow) => entry.id === rowId); + const col = columns.findIndex((entry: DatabaseViewModelColumn) => entry.id === columnId); + if (row < 0 || col < 0) { + return null; + } + return { row, col }; + } + + function findVisibleCellCoordByStorage( + row: number, + col: number, + ): { row: number; col: number } | null { + const rowIndex = visibleRows.findIndex( + (entry: DatabaseViewModelRow) => entry.crdtRowIndex === row, + ); + const colIndex = columns.findIndex( + (entry: DatabaseViewModelColumn) => entry.columnIndex === col, + ); + if (rowIndex < 0 || colIndex < 0) { + return null; + } + return { row: rowIndex, col: colIndex }; + } + + function normalizeDatabaseCellSelection( + selection: CellSelection, + ): CellSelection | null { + if (columns.length === 0) { + return null; + } + if (visibleRows.length === 0) { + return { + type: "cell", + blockId, + anchor: selection.anchor, + head: selection.head, + }; + } + + const firstVisibleCell = { row: 0, col: 0 }; + const anchorCoord = + findVisibleCellCoordByIds( + selection.rowIds?.[selection.anchor.row] ?? null, + selection.columnIds?.[selection.anchor.col] ?? null, + ) ?? + findVisibleCellCoordByStorage( + selection.anchor.row, + selection.anchor.col, + ) ?? + firstVisibleCell; + const headCoord = + findVisibleCellCoordByIds( + selection.rowIds?.[selection.head.row] ?? null, + selection.columnIds?.[selection.head.col] ?? null, + ) ?? + findVisibleCellCoordByStorage( + selection.head.row, + selection.head.col, + ) ?? + anchorCoord; + + return createDatabaseCellSelection(anchorCoord, headCoord); + } + + function areSelectionAxesEqual( + left: string[] | undefined, + right: string[], + ): boolean { + if (!left || left.length !== right.length) { + return false; + } + return left.every((value, index) => value === right[index]); + } + + function isDatabaseSelectionCurrent(selection: CellSelection): boolean { + if (visibleRows.length === 0) { + return !selection.rowIds && !selection.columnIds; + } + + return ( + areSelectionAxesEqual(selection.rowIds, visibleRowIds) && + areSelectionAxesEqual(selection.columnIds, visibleSelectionColumnIds) + ); + } + function handleCellMouseDown( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, + ) { + if (!fieldEditor) return; + const isEditing = + fieldEditorActiveCell?.blockId === blockId + && fieldEditorActiveCell.row === row.crdtRowIndex + && fieldEditorActiveCell.col === column.columnIndex; + if (isEditing) return; + const nextCoord = findVisibleCellCoordByIds(row.id, column.id); + if (!nextCoord) return; + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation?.(); + const isSameSingleCellSelection = + cellSelection && + cellSelection.anchor.row === nextCoord.row && + cellSelection.anchor.col === nextCoord.col && + cellSelection.head.row === nextCoord.row && + cellSelection.head.col === nextCoord.col; + if (!event.shiftKey && isSameSingleCellSelection) { + editor.selectBlock(blockId); + return; + } + if (event.shiftKey && cellSelection) { + editor.setSelection( + createDatabaseCellSelection(cellSelection.anchor, nextCoord), + ); + return; + } + editor.setSelection(createDatabaseCellSelection(nextCoord)); + } + + function handleCellDoubleClick( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, + ) { + if (isDataReadonly || !fieldEditor) return; + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation?.(); + const cellSurface = event.currentTarget.querySelector(`[${DATA_ATTRS.fieldEditorSurface}]`) as HTMLElement | null; + if (cellSurface) { + fieldEditor.activateCellFromElement?.(blockId, row.crdtRowIndex, column.columnIndex, cellSurface) + ?? fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); + return; + } + fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); + } + function handleToggleAllRows() { + if (allVisibleSelected) { + const nextSelection = { ...rowSelection }; + for (const rowId of visibleRowIds) { + delete nextSelection[rowId]; + } + setRowSelection(nextSelection); + return; + } + const nextSelection = { ...rowSelection }; + for (const rowId of visibleRowIds) { + nextSelection[rowId] = true; + } + setRowSelection(nextSelection); + } + + function handleToggleRow(rowId: string) { + setRowSelection((previous: Record) => ({ + ...previous, + [rowId]: !previous[rowId], + })); + } + + function getSelectedRowIds( + fallback?: { rowId: string; checked: boolean }, + ): string[] { + const selectedRowIds = allRows + .filter((row: DatabaseViewModelRow) => rowSelection[row.id]) + .map((row: DatabaseViewModelRow) => row.id); + if ( + fallback?.checked && + !selectedRowIds.includes(fallback.rowId) + ) { + selectedRowIds.push(fallback.rowId); + } + return selectedRowIds; + } + + function handleRowSelectionKeyDown( + event: React.KeyboardEvent, + rowId: string, + ) { + if (event.key !== "Backspace" && event.key !== "Delete") { + return; + } + event.preventDefault(); + event.stopPropagation(); + handleDeleteSelectedRows({ + rowId, + checked: event.currentTarget.checked, + }); + } + + function handleDeleteSelectedRows( + fallback?: { rowId: string; checked: boolean }, + ) { + const selectedRowIds = getSelectedRowIds(fallback); + if (selectedRowIds.length === 0 || isDataReadonly) return; + editor.apply([ + { + type: "database-delete-rows", + blockId, + rowIds: selectedRowIds, + }, + ], { origin: "user" }); + setRowSelection({}); + } + + function handlePinSelectedRows(target: "top" | "bottom" | "none") { + const selectedRowIds = getSelectedRowIds(); + if (selectedRowIds.length === 0) { + return; + } + const currentRowPinning = viewState.rowPinning; + const nextRowPinning = getNextRowPinningState( + currentRowPinning, + selectedRowIds, + target, + ); + updateViewState({ rowPinning: nextRowPinning, pageIndex: 0 }); + } + + return { + createDatabaseCellSelection, + findVisibleCellCoordByIds, + normalizeDatabaseCellSelection, + isDatabaseSelectionCurrent, + handleCellMouseDown, + handleCellDoubleClick, + handleToggleAllRows, + handleToggleRow, + getSelectedRowIds, + handleRowSelectionKeyDown, + handleDeleteSelectedRows, + handlePinSelectedRows, + }; +} diff --git a/packages/extensions/database/src/databaseControllerTypes.ts b/packages/extensions/database/src/databaseControllerTypes.ts new file mode 100644 index 0000000..4540711 --- /dev/null +++ b/packages/extensions/database/src/databaseControllerTypes.ts @@ -0,0 +1,137 @@ +import type { BlockHandle, CellSelection } from "@pen/types"; +import type React from "react"; +import type { DatabaseEngine } from "./engine"; +import type { + getColumnStickyStyle, + getFixedEdgeStyle, +} from "./utils/databaseRenderer"; +import type { + ColumnType, + DatabaseColumnDef, + DatabasePage, + DatabaseViewModel, + DatabaseViewModelColumn, + DatabaseViewModelRow, + DatabaseViewState, + FacetBucket, + FilterGroup, +} from "./types"; + +export type CellPointerHandler = ( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, +) => void; + +export interface DatabaseControllerConfig { + blockId: string; +} + +export interface DatabaseController { + block: BlockHandle; + engine: DatabaseEngine; + viewModel: DatabaseViewModel; + columnSchema: DatabaseColumnDef[]; + + viewState: DatabaseViewState; + updateViewState: (patch: Partial>) => void; + views: readonly DatabaseViewState[]; + + title: string; + isEditingTitle: boolean; + setIsEditingTitle: (editing: boolean) => void; + handleTitleClick: () => void; + handleTitleBlur: (event: React.FocusEvent) => void; + handleTitleKeyDown: (event: React.KeyboardEvent) => void; + + addRow: () => void; + addColumn: () => void; + deleteColumn: (columnId: string) => void; + renameColumn: (columnId: string, title: string) => void; + changeColumnType: (columnId: string, type: ColumnType) => void; + toggleColumnVisibility: (columnId: string) => void; + changeColumnPin: (columnId: string, pinned: "left" | "right" | undefined) => void; + addOption: (columnId: string, value: string, color?: string) => void; + renameOption: (columnId: string, optionId: string, value: string) => void; + recolorOption: (columnId: string, optionId: string, color: string) => void; + removeOption: (columnId: string, optionId: string) => void; + moveOption: (columnId: string, optionId: string, direction: "up" | "down") => void; + + addView: (type: DatabaseViewState["type"]) => void; + setActiveView: (viewId: string) => void; + removeView: (viewId: string) => void; + showAddViewMenu: boolean; + setShowAddViewMenu: (show: boolean) => void; + + rowSelection: Record; + toggleRow: (rowId: string) => void; + toggleAllRows: () => void; + deleteSelectedRows: () => void; + pinSelectedRows: (target: "top" | "bottom" | "none") => void; + handleRowSelectionKeyDown: (event: React.KeyboardEvent, rowId: string) => void; + hasSelectedRows: boolean; + selectedRowCount: number; + allVisibleSelected: boolean; + + cellSelection: CellSelection | null; + createCellSelection: (anchor: { row: number; col: number }, head?: { row: number; col: number }) => CellSelection; + handleCellMouseDown: CellPointerHandler; + handleCellDoubleClick: CellPointerHandler; + + globalSearch: string; + setGlobalSearch: (value: string) => void; + + filterGroup: FilterGroup; + handleFilterGroupChange: (filter: FilterGroup | null) => void; + facetBucketsByColumnId: Record; + showFilterPanel: boolean; + setShowFilterPanel: (show: boolean) => void; + + handleSortChange: (sort: NonNullable) => void; + handleHeaderClick: (event: React.MouseEvent, columnId: string) => void; + showSortPanel: boolean; + setShowSortPanel: (show: boolean) => void; + + handleChangeGroupBy: (groupBy: string | null) => void; + showGroupPanel: boolean; + setShowGroupPanel: (show: boolean) => void; + + showColumnVisibilityMenu: boolean; + setShowColumnVisibilityMenu: (show: boolean) => void; + + activeColumnMenu: string | null; + setActiveColumnMenu: (columnId: string | null) => void; + + handlePreviousPage: () => void; + handleNextPage: () => void; + pageCount: number; + showPagination: boolean; + + remoteLoading: boolean; + remoteError: string | null; + + isUiReadonly: boolean; + isDataReadonly: boolean; + showRowSelectionControls: boolean; + + columns: DatabaseViewModelColumn[]; + allRows: DatabaseViewModelRow[]; + rows: DatabaseViewModelRow[]; + pinnedTopRows: DatabaseViewModelRow[]; + pinnedBottomRows: DatabaseViewModelRow[]; + rowGroups: DatabaseViewModel["rowGroups"]; + visibleRows: DatabaseViewModelRow[]; + visibleColumnIds: string[]; + visibleColumnIdSet: ReadonlySet; + + defaultColumnWidth: number; + pinnedOffsets: Record; + getColumnStickyStyle: typeof getColumnStickyStyle; + getFixedEdgeStyle: typeof getFixedEdgeStyle; + isCellSelected: (row: number, column: number) => boolean; + formatRemoteCell: (row: DatabaseViewModelRow, column: DatabaseViewModelColumn) => string; + + calendarMonth: Date; + shiftCalendarMonth: (amount: number) => void; + calendarDateColumn: DatabaseColumnDef | undefined; +} diff --git a/packages/extensions/database/src/engine.ts b/packages/extensions/database/src/engine.ts index 7fd984a..a78b623 100644 --- a/packages/extensions/database/src/engine.ts +++ b/packages/extensions/database/src/engine.ts @@ -1,867 +1,4 @@ -import { - coerceDatabaseValue, - formatStoredMultiSelectValue, - formatStoredSelectValue, - parseDatabaseMultiSelectValue, - resolveStoredSelectOption, -} from "@pen/types"; -import type { BlockHandle, Editor } from "@pen/types"; -import type { - ColumnType, - DatabaseColumnDef, - DatabaseDataProvider, - DatabasePage, - DatabaseQuery, - DatabaseRowGroup, - DatabaseRowPinning, - DatabaseRow, - DatabaseSort, - FacetBucket, - DatabaseViewModel, - DatabaseViewModelColumn, - DatabaseViewModelRow, - DatabaseViewState, - FilterCondition, - FilterGroup, - FilterOperator, - NumberFormat, - DateFormat, -} from "./types"; +import "./engineRows"; +import "./engineFilters"; -const DEFAULT_PAGE_SIZE = 50; -const VALID_COLUMN_TYPES = new Set([ - "text", - "number", - "checkbox", - "select", - "multiSelect", - "date", - "url", - "email", - "relation", - "formula", -]); - -export class DatabaseEngine { - private readonly _editor: Editor; - private readonly _blockId: string; - private _dataProvider: DatabaseDataProvider | null = null; - - constructor(editor: Editor, blockId: string) { - this._editor = editor; - this._blockId = blockId; - } - - get blockId(): string { - return this._blockId; - } - - get editor(): Editor { - return this._editor; - } - - get dataProvider(): DatabaseDataProvider | null { - return this._dataProvider; - } - - setDataProvider(provider: DatabaseDataProvider): void { - this._dataProvider = provider; - } - - get isRemote(): boolean { - const block = this._block; - return block?.props.dataSource === "remote" || block?.props.dataSource === "hybrid"; - } - - private get _block(): BlockHandle | null { - return this._editor.getBlock(this._blockId) ?? null; - } - - deriveColumnSchema(): DatabaseColumnDef[] { - const block = this._block; - if (!block) return []; - return block.tableColumns().map((column, index) => ({ - id: column.id || `col-${index}`, - title: column.title || "Untitled", - type: this.normalizeColumnType(column.type), - width: column.width, - hidden: column.hidden, - pinned: column.pinned, - options: column.options, - format: column.format, - readonly: column.readonly, - })); - } - - deriveRowData(): DatabaseRow[] { - const block = this._block; - if (!block) return []; - const columns = this.deriveColumnSchema(); - const rowCount = block.tableRowCount(); - const columnCount = Math.max(columns.length, block.tableColumnCount()); - const rows: DatabaseRow[] = []; - - for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { - const rowHandle = typeof block.tableRow === "function" ? block.tableRow(rowIndex) : null; - const cells: Record = {}; - for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { - const columnId = columns[columnIndex]?.id ?? `col-${columnIndex}`; - cells[columnId] = block.tableCell(rowIndex, columnIndex)?.textContent() ?? ""; - } - rows.push({ - id: rowHandle?.id ?? `row-${rowIndex}`, - crdtRowIndex: rowIndex, - cells, - }); - } - - return rows; - } - - deriveViewState(): DatabaseViewState { - const block = this._block; - const columns = this.deriveColumnSchema(); - const fallbackColumnIds = columns.map((column) => column.id); - const activeView = block?.databaseActiveView(); - if (activeView) { - return { - ...activeView, - visibleColumnIds: activeView.visibleColumnIds ?? fallbackColumnIds, - columnOrder: activeView.columnOrder ?? fallbackColumnIds, - pageIndex: activeView.pageIndex ?? 0, - pageSize: activeView.pageSize ?? DEFAULT_PAGE_SIZE, - }; - } - - return { - id: "default", - title: "Table view", - type: "table", - visibleColumnIds: fallbackColumnIds, - columnOrder: fallbackColumnIds, - sort: [], - filter: null, - pageIndex: 0, - pageSize: DEFAULT_PAGE_SIZE, - }; - } - - createQuery(options?: { - view?: DatabaseViewState | null; - override?: Partial; - }): DatabaseQuery { - const view = options?.view ?? this.deriveViewState(); - return { - sort: options?.override?.sort ?? view.sort, - filter: options?.override?.filter ?? view.filter ?? undefined, - groupBy: options?.override?.groupBy ?? view.groupBy ?? null, - pageIndex: options?.override?.pageIndex ?? view.pageIndex ?? 0, - pageSize: options?.override?.pageSize ?? view.pageSize ?? DEFAULT_PAGE_SIZE, - }; - } - - buildViewModel(options?: { - view?: DatabaseViewState | null; - rows?: DatabaseRow[]; - globalSearch?: string; - totalRows?: number; - remotePage?: boolean; - }): DatabaseViewModel { - const view = options?.view ?? this.deriveViewState(); - const columns = this.deriveViewColumns(view); - const pageIndex = view.pageIndex ?? 0; - const pageSize = view.pageSize ?? DEFAULT_PAGE_SIZE; - const sourceRows = options?.rows ?? this.deriveRowData(); - - if (options?.remotePage) { - const totalRows = options?.totalRows ?? sourceRows.length; - const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); - return { - view, - columns, - allRows: sourceRows, - pinnedTopRows: [], - rows: sourceRows, - pinnedBottomRows: [], - rowGroups: this.groupRows(sourceRows, view.groupBy ?? null, columns), - totalRows, - pageIndex, - pageSize, - pageCount, - }; - } - - const searchedRows = this.searchRows(sourceRows, options?.globalSearch ?? "", columns); - const filteredRows = this.filterRows(searchedRows, view.filter ?? null, columns); - const sortedRows = this.sortRows(filteredRows, view.sort ?? [], columns); - const pinnedRows = this.splitPinnedRows(sortedRows, view.rowPinning); - const rows = this.paginateRows(pinnedRows.rows, pageIndex, pageSize); - const totalRows = pinnedRows.rows.length; - const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); - - return { - view, - columns, - allRows: sortedRows, - pinnedTopRows: pinnedRows.top, - rows, - pinnedBottomRows: pinnedRows.bottom, - rowGroups: this.groupRows(rows, view.groupBy ?? null, columns), - totalRows, - pageIndex, - pageSize, - pageCount, - }; - } - - buildRemoteViewModel(page: DatabasePage, view?: DatabaseViewState | null): DatabaseViewModel { - return this.buildViewModel({ - view, - rows: page.rows, - totalRows: page.totalRows, - remotePage: true, - }); - } - - searchRows( - rows: DatabaseRow[], - globalSearch: string, - columns?: DatabaseViewModelColumn[], - ): DatabaseRow[] { - const query = globalSearch.trim().toLowerCase(); - if (!query) return rows; - const searchColumns = columns?.length ? columns : null; - return rows.filter((row) => - (searchColumns ?? Object.keys(row.cells).map((columnId) => ({ - id: columnId, - type: "text" as ColumnType, - columnIndex: 0, - title: columnId, - format: undefined, - options: undefined, - }))).some((column) => - this.formatCellDisplay( - row.cells[column.id] ?? "", - column.type, - column.format, - column.options, - ) - .toLowerCase() - .includes(query), - ), - ); - } - - filterRows(rows: DatabaseRow[], filter: FilterGroup | null, columns: DatabaseViewModelColumn[]): DatabaseRow[] { - if (!filter || filter.conditions.length === 0) return rows; - return rows.filter((row) => this.matchesFilterGroup(row, filter, columns)); - } - - sortRows(rows: DatabaseRow[], sorts: DatabaseSort[], columns: DatabaseViewModelColumn[]): DatabaseRow[] { - if (sorts.length === 0) return rows; - const columnMap = new Map(columns.map((column) => [column.id, column])); - return [...rows].sort((left, right) => { - for (const sort of sorts) { - const column = columnMap.get(sort.columnId); - if (!column) continue; - const compare = this.compareCellValues( - left.cells[sort.columnId] ?? "", - right.cells[sort.columnId] ?? "", - column.type, - column.options, - ); - if (compare !== 0) { - return sort.direction === "desc" ? -compare : compare; - } - } - return left.crdtRowIndex - right.crdtRowIndex; - }); - } - - paginateRows(rows: DatabaseRow[], pageIndex: number, pageSize: number): DatabaseViewModelRow[] { - const normalizedPageSize = Math.max(1, pageSize); - const normalizedPageIndex = Math.max(0, pageIndex); - const start = normalizedPageIndex * normalizedPageSize; - return rows.slice(start, start + normalizedPageSize); - } - - splitPinnedRows( - rows: DatabaseRow[], - rowPinning?: DatabaseRowPinning, - ): { - top: DatabaseViewModelRow[]; - rows: DatabaseViewModelRow[]; - bottom: DatabaseViewModelRow[]; - } { - const rowMap = new Map(rows.map((row) => [row.id, row])); - const topRowIds: string[] = [...(rowPinning?.top ?? [])]; - const bottomRowIds: string[] = [...(rowPinning?.bottom ?? [])]; - const top = topRowIds - .map((rowId: string) => rowMap.get(rowId)) - .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); - const bottom = bottomRowIds - .map((rowId: string) => rowMap.get(rowId)) - .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); - const pinnedIds = new Set([...top, ...bottom].map((row) => row.id)); - return { - top, - rows: rows.filter((row) => !pinnedIds.has(row.id)), - bottom, - }; - } - - groupRows( - rows: DatabaseViewModelRow[], - groupBy: string | null, - columns: DatabaseViewModelColumn[], - ): DatabaseRowGroup[] { - if (!groupBy) { - return []; - } - const column = this.resolveGroupingColumn(groupBy, columns); - if (!column) { - return []; - } - const groups: DatabaseRowGroup[] = []; - const groupByKey = new Map(); - for (const row of rows) { - const label = this.formatGroupLabel(row.cells[column.id] ?? "", column); - const key = `${column.id}:${label}`; - const existing = groupByKey.get(key); - if (existing) { - existing.rows.push(row); - continue; - } - const nextGroup: DatabaseRowGroup = { - key, - label, - rows: [row], - }; - groupByKey.set(key, nextGroup); - groups.push(nextGroup); - } - return groups; - } - - facetColumnValues( - rows: DatabaseRow[], - columnId: string, - columns: DatabaseViewModelColumn[], - ): FacetBucket[] { - const column = columns.find((entry) => entry.id === columnId); - if (!column) return []; - const buckets = new Map(); - for (const row of rows) { - const raw = row.cells[columnId] ?? ""; - if (!raw) continue; - if (column.type === "multiSelect") { - const values = parseDatabaseMultiSelectValue(raw); - for (const value of values) { - const option = resolveStoredSelectOption(value, column.options); - const bucketValue = option?.id ?? value; - const bucketLabel = option?.value ?? value; - this.incrementFacetBucket(buckets, bucketValue, bucketLabel); - } - continue; - } - if (column.type === "select") { - const option = resolveStoredSelectOption(raw, column.options); - const bucketValue = option?.id ?? raw; - const bucketLabel = option?.value ?? raw; - this.incrementFacetBucket(buckets, bucketValue, bucketLabel); - continue; - } - if (column.type === "checkbox") { - const bucketValue = raw.toLowerCase() === "true" ? "true" : "false"; - const bucketLabel = bucketValue === "true" ? "Checked" : "Unchecked"; - this.incrementFacetBucket(buckets, bucketValue, bucketLabel); - continue; - } - this.incrementFacetBucket(buckets, raw, raw); - } - return [...buckets.values()].sort((left, right) => - left.label.toLowerCase().localeCompare(right.label.toLowerCase()), - ); - } - - parseCellValue(raw: string, columnType: ColumnType): unknown { - switch (columnType) { - case "number": { - if (raw === "") return null; - const value = Number(raw); - return Number.isNaN(value) ? null : value; - } - case "checkbox": - return raw.toLowerCase() === "true"; - case "date": { - if (raw === "") return null; - const value = new Date(raw); - return Number.isNaN(value.getTime()) ? null : value; - } - case "select": - return raw === "" ? null : raw; - case "multiSelect": - return parseDatabaseMultiSelectValue(raw); - default: - return raw; - } - } - - serializeCellValue(value: unknown, columnType: ColumnType): string { - if (value == null) return ""; - switch (columnType) { - case "checkbox": - return value ? "true" : "false"; - case "date": - return value instanceof Date ? value.toISOString() : String(value); - case "select": - return String(value); - case "multiSelect": - return Array.isArray(value) ? JSON.stringify(value) : ""; - default: - return String(value); - } - } - - validateCellValue(raw: string, columnType: ColumnType): string | null { - switch (columnType) { - case "number": - return raw !== "" && Number.isNaN(Number(raw)) ? "Invalid number" : null; - case "date": - return raw !== "" && Number.isNaN(new Date(raw).getTime()) ? "Invalid date" : null; - case "email": - return raw !== "" && !raw.includes("@") ? "Invalid email" : null; - case "url": - if (raw === "") return null; - try { - new URL(raw); - return null; - } catch { - return "Invalid URL"; - } - default: - return null; - } - } - - formatCellDisplay( - raw: string, - columnType: ColumnType, - format?: NumberFormat | DateFormat, - options?: DatabaseColumnDef["options"], - ): string { - if (raw === "") return ""; - switch (columnType) { - case "number": { - const value = Number(raw); - if (Number.isNaN(value)) return raw; - const numberFormat = format as NumberFormat | undefined; - if (numberFormat?.style === "currency" && numberFormat.currency) { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: numberFormat.currency, - minimumFractionDigits: numberFormat.decimals, - maximumFractionDigits: numberFormat.decimals, - }).format(value); - } - if (numberFormat?.style === "percent") { - return new Intl.NumberFormat(undefined, { - style: "percent", - minimumFractionDigits: numberFormat.decimals, - maximumFractionDigits: numberFormat.decimals, - }).format(value); - } - if (numberFormat?.decimals != null) { - return value.toFixed(numberFormat.decimals); - } - return String(value); - } - case "date": { - const value = new Date(raw); - if (Number.isNaN(value.getTime())) return raw; - const dateFormat = format as DateFormat | undefined; - const options: Intl.DateTimeFormatOptions = { - dateStyle: dateFormat?.dateStyle ?? "medium", - }; - if (dateFormat?.includeTime) { - options.timeStyle = "short"; - } - return new Intl.DateTimeFormat(undefined, options).format(value); - } - case "checkbox": - return raw.toLowerCase() === "true" ? "✓" : ""; - case "select": - return formatStoredSelectValue(raw, options); - case "multiSelect": - return formatStoredMultiSelectValue(raw, options); - default: - return raw; - } - } - - coerceValue( - raw: string, - fromType: ColumnType, - toType: ColumnType, - options?: DatabaseColumnDef["options"], - ): string { - return coerceDatabaseValue(raw, fromType, toType, options); - } - - getRowId(row: DatabaseRow): string { - return row.id; - } - - private deriveViewColumns(view: DatabaseViewState): DatabaseViewModelColumn[] { - const schema = this.deriveColumnSchema(); - const schemaById = new Map(schema.map((column, columnIndex) => [column.id, { column, columnIndex }])); - const columnOrder = view.columnOrder ?? schema.map((column) => column.id); - const visibleColumnIds = new Set( - view.visibleColumnIds ?? schema.filter((column) => !column.hidden).map((column) => column.id), - ); - const orderedIds = [ - ...columnOrder, - ...schema.map((column) => column.id).filter((columnId) => !columnOrder.includes(columnId)), - ]; - - const orderedColumns = orderedIds - .map((columnId) => schemaById.get(columnId)) - .filter((entry): entry is { column: DatabaseColumnDef; columnIndex: number } => entry != null) - .filter(({ column }) => !column.hidden && visibleColumnIds.has(column.id)) - .map(({ column, columnIndex }) => ({ - id: column.id, - title: column.title, - type: this.normalizeColumnType(column.type), - columnIndex, - width: column.width, - hidden: column.hidden, - pinned: column.pinned, - options: column.options, - format: column.format, - readonly: column.readonly, - })); - - const leftColumns = orderedColumns.filter((column) => column.pinned === "left"); - const centerColumns = orderedColumns.filter((column) => column.pinned == null); - const rightColumns = orderedColumns.filter((column) => column.pinned === "right"); - return [...leftColumns, ...centerColumns, ...rightColumns]; - } - - private matchesFilterGroup(row: DatabaseRow, filterGroup: FilterGroup, columns: DatabaseViewModelColumn[]): boolean { - const results = filterGroup.conditions.map((condition) => - this.isFilterGroup(condition) - ? this.matchesFilterGroup(row, condition, columns) - : this.matchesFilterCondition(row, condition, columns), - ); - return filterGroup.operator === "or" ? results.some(Boolean) : results.every(Boolean); - } - - private matchesFilterCondition(row: DatabaseRow, condition: FilterCondition, columns: DatabaseViewModelColumn[]): boolean { - const column = columns.find((entry) => entry.id === condition.columnId); - if (!column) return true; - return this.matchesOperator( - row.cells[condition.columnId] ?? "", - condition.operator, - condition.value, - column.type, - column.options, - ); - } - - private matchesOperator( - rawValue: string, - operator: FilterOperator, - filterValue: string | string[] | null, - columnType: ColumnType, - options?: DatabaseColumnDef["options"], - ): boolean { - const normalizedRawValue = - columnType === "select" - ? resolveStoredSelectOption(rawValue, options)?.id ?? rawValue - : rawValue; - if (columnType === "date") { - return this.matchesDateOperator(normalizedRawValue, operator, filterValue); - } - const lowerValue = normalizedRawValue.toLowerCase(); - switch (operator) { - case "is": - return lowerValue === String(filterValue ?? "").toLowerCase(); - case "is_not": - return lowerValue !== String(filterValue ?? "").toLowerCase(); - case "contains": - if (columnType === "multiSelect") { - return parseDatabaseMultiSelectValue(rawValue).includes( - String(filterValue ?? ""), - ); - } - return lowerValue.includes(String(filterValue ?? "").toLowerCase()); - case "not_contains": - if (columnType === "multiSelect") { - return !parseDatabaseMultiSelectValue(rawValue).includes( - String(filterValue ?? ""), - ); - } - return !lowerValue.includes(String(filterValue ?? "").toLowerCase()); - case "starts_with": - return lowerValue.startsWith(String(filterValue ?? "").toLowerCase()); - case "ends_with": - return lowerValue.endsWith(String(filterValue ?? "").toLowerCase()); - case "is_empty": - return rawValue === ""; - case "is_not_empty": - return rawValue !== ""; - case "is_checked": - return rawValue.toLowerCase() === "true"; - case "is_unchecked": - return rawValue.toLowerCase() !== "true"; - case "is_any_of": { - const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; - if (columnType === "multiSelect") { - const selectedValues = parseDatabaseMultiSelectValue(rawValue); - return values.some((value) => selectedValues.includes(value)); - } - return values.includes(normalizedRawValue); - } - case "is_none_of": { - const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; - if (columnType === "multiSelect") { - const selectedValues = parseDatabaseMultiSelectValue(rawValue); - return values.every((value) => !selectedValues.includes(value)); - } - return !values.includes(normalizedRawValue); - } - case "=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) === 0; - case "!=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) !== 0; - case ">": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) > 0; - case "<": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) < 0; - case ">=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) >= 0; - case "<=": - return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) <= 0; - default: - return true; - } - } - - private matchesDateOperator( - rawValue: string, - operator: FilterOperator, - filterValue: string | string[] | null, - ): boolean { - if (operator === "is_empty") { - return rawValue === ""; - } - if (operator === "is_not_empty") { - return rawValue !== ""; - } - const rawDate = this.parseFilterDate(rawValue); - if (!rawDate) { - return false; - } - switch (operator) { - case "is": { - const targetDate = this.parseFilterDate(String(filterValue ?? "")); - return targetDate ? this.isSameCalendarDay(rawDate, targetDate) : false; - } - case "is_before": { - const targetDate = this.parseFilterDate(String(filterValue ?? "")); - return targetDate ? this.startOfCalendarDay(rawDate) < this.startOfCalendarDay(targetDate) : false; - } - case "is_after": { - const targetDate = this.parseFilterDate(String(filterValue ?? "")); - return targetDate ? this.startOfCalendarDay(rawDate) > this.startOfCalendarDay(targetDate) : false; - } - case "is_between": { - if (!Array.isArray(filterValue) || filterValue.length < 2) { - return false; - } - const startDate = this.parseFilterDate(filterValue[0] ?? ""); - const endDate = this.parseFilterDate(filterValue[1] ?? ""); - if (!startDate || !endDate) { - return false; - } - const rawTime = this.startOfCalendarDay(rawDate); - return rawTime >= this.startOfCalendarDay(startDate) && rawTime <= this.startOfCalendarDay(endDate); - } - case "is_relative": - return this.matchesRelativeDate(rawDate, String(filterValue ?? "")); - default: - return true; - } - } - - private compareCellValues( - left: string, - right: string, - columnType: ColumnType, - options?: DatabaseColumnDef["options"], - ): number { - if (columnType === "select") { - return this.comparePrimitive( - formatStoredSelectValue(left, options), - formatStoredSelectValue(right, options), - "text", - ); - } - if (columnType === "multiSelect") { - return this.comparePrimitive( - formatStoredMultiSelectValue(left, options), - formatStoredMultiSelectValue(right, options), - "text", - ); - } - return this.comparePrimitive(left, right, columnType); - } - - private comparePrimitive(left: string, right: string, columnType: ColumnType): number { - switch (columnType) { - case "number": - return (Number(left) || 0) - (Number(right) || 0); - case "date": - return (new Date(left).getTime() || 0) - (new Date(right).getTime() || 0); - case "checkbox": - return (left.toLowerCase() === "true" ? 1 : 0) - (right.toLowerCase() === "true" ? 1 : 0); - default: - return left.toLowerCase().localeCompare(right.toLowerCase()); - } - } - - private parseFilterDate(raw: string): Date | null { - if (!raw) { - return null; - } - const value = new Date(raw); - return Number.isNaN(value.getTime()) ? null : value; - } - - private startOfCalendarDay(value: Date): number { - return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); - } - - private endOfCalendarDay(value: Date): number { - return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999).getTime(); - } - - private startOfCalendarWeek(value: Date): number { - const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); - nextValue.setDate(nextValue.getDate() - nextValue.getDay()); - return nextValue.getTime(); - } - - private endOfCalendarWeek(value: Date): number { - const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); - nextValue.setDate(nextValue.getDate() + (6 - nextValue.getDay())); - return this.endOfCalendarDay(nextValue); - } - - private startOfCalendarMonth(value: Date): number { - return new Date(value.getFullYear(), value.getMonth(), 1).getTime(); - } - - private endOfCalendarMonth(value: Date): number { - return new Date(value.getFullYear(), value.getMonth() + 1, 0, 23, 59, 59, 999).getTime(); - } - - private isSameCalendarDay(left: Date, right: Date): boolean { - return left.getFullYear() === right.getFullYear() - && left.getMonth() === right.getMonth() - && left.getDate() === right.getDate(); - } - - private matchesRelativeDate(rawDate: Date, relativeValue: string): boolean { - const now = new Date(); - const rawTime = rawDate.getTime(); - const todayStart = this.startOfCalendarDay(now); - switch (relativeValue) { - case "today": - return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(now); - case "yesterday": { - const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); - return rawTime >= this.startOfCalendarDay(yesterday) && rawTime <= this.endOfCalendarDay(yesterday); - } - case "tomorrow": { - const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); - return rawTime >= this.startOfCalendarDay(tomorrow) && rawTime <= this.endOfCalendarDay(tomorrow); - } - case "this_week": - return rawTime >= this.startOfCalendarWeek(now) && rawTime <= this.endOfCalendarWeek(now); - case "last_7_days": { - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6); - return rawTime >= this.startOfCalendarDay(start) && rawTime <= this.endOfCalendarDay(now); - } - case "next_7_days": { - const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 6); - return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(end); - } - case "this_month": - return rawTime >= this.startOfCalendarMonth(now) && rawTime <= this.endOfCalendarMonth(now); - default: - return false; - } - } - - private incrementFacetBucket( - buckets: Map, - value: string, - label: string, - ): void { - const existing = buckets.get(value); - if (existing) { - existing.count += 1; - return; - } - buckets.set(value, { value, label, count: 1 }); - } - - private formatGroupLabel( - raw: string, - column: DatabaseViewModelColumn, - ): string { - const formatted = this.formatCellDisplay( - raw, - column.type, - column.format, - column.options, - ); - return formatted || "(empty)"; - } - - private resolveGroupingColumn( - groupBy: string, - columns: DatabaseViewModelColumn[], - ): DatabaseViewModelColumn | null { - const visibleColumn = columns.find((entry) => entry.id === groupBy); - if (visibleColumn) { - return visibleColumn; - } - const schema = this.deriveColumnSchema(); - const schemaColumn = schema.find((entry) => entry.id === groupBy); - if (!schemaColumn) { - return null; - } - return { - id: schemaColumn.id, - title: schemaColumn.title, - type: this.normalizeColumnType(schemaColumn.type), - columnIndex: schema.findIndex((entry) => entry.id === groupBy), - width: schemaColumn.width, - hidden: schemaColumn.hidden, - pinned: schemaColumn.pinned, - options: schemaColumn.options, - format: schemaColumn.format, - readonly: schemaColumn.readonly, - }; - } - - private normalizeColumnType(type: string | undefined): ColumnType { - return type && VALID_COLUMN_TYPES.has(type as ColumnType) ? (type as ColumnType) : "text"; - } - - private isFilterGroup(value: FilterCondition | FilterGroup): value is FilterGroup { - return "conditions" in value; - } -} +export { DatabaseEngine } from "./engineCore"; diff --git a/packages/extensions/database/src/engineCore.ts b/packages/extensions/database/src/engineCore.ts new file mode 100644 index 0000000..e83c2e3 --- /dev/null +++ b/packages/extensions/database/src/engineCore.ts @@ -0,0 +1,453 @@ +import { + coerceDatabaseValue, + formatStoredMultiSelectValue, + formatStoredSelectValue, + parseDatabaseMultiSelectValue, + resolveStoredSelectOption, +} from "@pen/types"; +import type { BlockHandle, Editor } from "@pen/types"; +import type { + ColumnType, + DatabaseColumnDef, + DatabaseDataProvider, + DatabasePage, + DatabaseQuery, + DatabaseRowGroup, + DatabaseRowPinning, + DatabaseRow, + DatabaseSort, + FacetBucket, + DatabaseViewModel, + DatabaseViewModelColumn, + DatabaseViewModelRow, + DatabaseViewState, + FilterCondition, + FilterGroup, + FilterOperator, + NumberFormat, + DateFormat, +} from "./types"; + +const DEFAULT_PAGE_SIZE = 50; +export const VALID_COLUMN_TYPES = new Set([ + "text", + "number", + "checkbox", + "select", + "multiSelect", + "date", + "url", + "email", + "relation", + "formula", +]); + +export interface DatabaseEngine { + filterRows(rows: DatabaseRow[], filter: FilterGroup | null, columns: DatabaseViewModelColumn[]): DatabaseRow[]; + sortRows(rows: DatabaseRow[], sorts: DatabaseSort[], columns: DatabaseViewModelColumn[]): DatabaseRow[]; + paginateRows(rows: DatabaseRow[], pageIndex: number, pageSize: number): DatabaseViewModelRow[]; + splitPinnedRows( + rows: DatabaseRow[], + rowPinning?: DatabaseRowPinning, + ): { + top: DatabaseViewModelRow[]; + rows: DatabaseViewModelRow[]; + bottom: DatabaseViewModelRow[]; + }; + groupRows( + rows: DatabaseViewModelRow[], + groupBy: string | null, + columns: DatabaseViewModelColumn[], + ): DatabaseRowGroup[]; + facetColumnValues( + rows: DatabaseRow[], + columnId: string, + columns: DatabaseViewModelColumn[], + ): FacetBucket[]; + deriveViewColumns(view: DatabaseViewState): DatabaseViewModelColumn[]; + matchesFilterGroup(row: DatabaseRow, filterGroup: FilterGroup, columns: DatabaseViewModelColumn[]): boolean; + matchesFilterCondition(row: DatabaseRow, condition: FilterCondition, columns: DatabaseViewModelColumn[]): boolean; + matchesOperator( + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], + ): boolean; + matchesDateOperator( + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, + ): boolean; + compareCellValues( + left: string, + right: string, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], + ): number; + comparePrimitive(left: string, right: string, columnType: ColumnType): number; + parseFilterDate(raw: string): Date | null; + startOfCalendarDay(value: Date): number; + endOfCalendarDay(value: Date): number; + startOfCalendarWeek(value: Date): number; + endOfCalendarWeek(value: Date): number; + startOfCalendarMonth(value: Date): number; + endOfCalendarMonth(value: Date): number; + isSameCalendarDay(left: Date, right: Date): boolean; + matchesRelativeDate(rawDate: Date, relativeValue: string): boolean; + incrementFacetBucket( + buckets: Map, + value: string, + label: string, + ): void; + formatGroupLabel( + raw: string, + column: DatabaseViewModelColumn, + ): string; + resolveGroupingColumn( + groupBy: string, + columns: DatabaseViewModelColumn[], + ): DatabaseViewModelColumn | null; + normalizeColumnType(type: string | undefined): ColumnType; + isFilterGroup(value: FilterCondition | FilterGroup): value is FilterGroup; +} + +export class DatabaseEngine { + private readonly _editor: Editor; + private readonly _blockId: string; + private _dataProvider: DatabaseDataProvider | null = null; + + constructor(editor: Editor, blockId: string) { + this._editor = editor; + this._blockId = blockId; + } + + get blockId(): string { + return this._blockId; + } + + get editor(): Editor { + return this._editor; + } + + get dataProvider(): DatabaseDataProvider | null { + return this._dataProvider; + } + + setDataProvider(provider: DatabaseDataProvider): void { + this._dataProvider = provider; + } + + get isRemote(): boolean { + const block = this._block; + return block?.props.dataSource === "remote" || block?.props.dataSource === "hybrid"; + } + + private get _block(): BlockHandle | null { + return this._editor.getBlock(this._blockId) ?? null; + } + + deriveColumnSchema(): DatabaseColumnDef[] { + const block = this._block; + if (!block) return []; + return block.tableColumns().map((column, index) => ({ + id: column.id || `col-${index}`, + title: column.title || "Untitled", + type: this.normalizeColumnType(column.type), + width: column.width, + hidden: column.hidden, + pinned: column.pinned, + options: column.options, + format: column.format, + readonly: column.readonly, + })); + } + + deriveRowData(): DatabaseRow[] { + const block = this._block; + if (!block) return []; + const columns = this.deriveColumnSchema(); + const rowCount = block.tableRowCount(); + const columnCount = Math.max(columns.length, block.tableColumnCount()); + const rows: DatabaseRow[] = []; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const rowHandle = typeof block.tableRow === "function" ? block.tableRow(rowIndex) : null; + const cells: Record = {}; + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + const columnId = columns[columnIndex]?.id ?? `col-${columnIndex}`; + cells[columnId] = block.tableCell(rowIndex, columnIndex)?.textContent() ?? ""; + } + rows.push({ + id: rowHandle?.id ?? `row-${rowIndex}`, + crdtRowIndex: rowIndex, + cells, + }); + } + + return rows; + } + + deriveViewState(): DatabaseViewState { + const block = this._block; + const columns = this.deriveColumnSchema(); + const fallbackColumnIds = columns.map((column) => column.id); + const activeView = block?.databaseActiveView(); + if (activeView) { + return { + ...activeView, + visibleColumnIds: activeView.visibleColumnIds ?? fallbackColumnIds, + columnOrder: activeView.columnOrder ?? fallbackColumnIds, + pageIndex: activeView.pageIndex ?? 0, + pageSize: activeView.pageSize ?? DEFAULT_PAGE_SIZE, + }; + } + + return { + id: "default", + title: "Table view", + type: "table", + visibleColumnIds: fallbackColumnIds, + columnOrder: fallbackColumnIds, + sort: [], + filter: null, + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + }; + } + + createQuery(options?: { + view?: DatabaseViewState | null; + override?: Partial; + }): DatabaseQuery { + const view = options?.view ?? this.deriveViewState(); + return { + sort: options?.override?.sort ?? view.sort, + filter: options?.override?.filter ?? view.filter ?? undefined, + groupBy: options?.override?.groupBy ?? view.groupBy ?? null, + pageIndex: options?.override?.pageIndex ?? view.pageIndex ?? 0, + pageSize: options?.override?.pageSize ?? view.pageSize ?? DEFAULT_PAGE_SIZE, + }; + } + + buildViewModel(options?: { + view?: DatabaseViewState | null; + rows?: DatabaseRow[]; + globalSearch?: string; + totalRows?: number; + remotePage?: boolean; + }): DatabaseViewModel { + const view = options?.view ?? this.deriveViewState(); + const columns = this.deriveViewColumns(view); + const pageIndex = view.pageIndex ?? 0; + const pageSize = view.pageSize ?? DEFAULT_PAGE_SIZE; + const sourceRows = options?.rows ?? this.deriveRowData(); + + if (options?.remotePage) { + const totalRows = options?.totalRows ?? sourceRows.length; + const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); + return { + view, + columns, + allRows: sourceRows, + pinnedTopRows: [], + rows: sourceRows, + pinnedBottomRows: [], + rowGroups: this.groupRows(sourceRows, view.groupBy ?? null, columns), + totalRows, + pageIndex, + pageSize, + pageCount, + }; + } + + const searchedRows = this.searchRows(sourceRows, options?.globalSearch ?? "", columns); + const filteredRows = this.filterRows(searchedRows, view.filter ?? null, columns); + const sortedRows = this.sortRows(filteredRows, view.sort ?? [], columns); + const pinnedRows = this.splitPinnedRows(sortedRows, view.rowPinning); + const rows = this.paginateRows(pinnedRows.rows, pageIndex, pageSize); + const totalRows = pinnedRows.rows.length; + const pageCount = Math.max(1, Math.ceil(totalRows / pageSize)); + + return { + view, + columns, + allRows: sortedRows, + pinnedTopRows: pinnedRows.top, + rows, + pinnedBottomRows: pinnedRows.bottom, + rowGroups: this.groupRows(rows, view.groupBy ?? null, columns), + totalRows, + pageIndex, + pageSize, + pageCount, + }; + } + + buildRemoteViewModel(page: DatabasePage, view?: DatabaseViewState | null): DatabaseViewModel { + return this.buildViewModel({ + view, + rows: page.rows, + totalRows: page.totalRows, + remotePage: true, + }); + } + + searchRows( + rows: DatabaseRow[], + globalSearch: string, + columns?: DatabaseViewModelColumn[], + ): DatabaseRow[] { + const query = globalSearch.trim().toLowerCase(); + if (!query) return rows; + const searchColumns = columns?.length ? columns : null; + return rows.filter((row) => + (searchColumns ?? Object.keys(row.cells).map((columnId) => ({ + id: columnId, + type: "text" as ColumnType, + columnIndex: 0, + title: columnId, + format: undefined, + options: undefined, + }))).some((column) => + this.formatCellDisplay( + row.cells[column.id] ?? "", + column.type, + column.format, + column.options, + ) + .toLowerCase() + .includes(query), + ), + ); + } + + parseCellValue(raw: string, columnType: ColumnType): unknown { + switch (columnType) { + case "number": { + if (raw === "") return null; + const value = Number(raw); + return Number.isNaN(value) ? null : value; + } + case "checkbox": + return raw.toLowerCase() === "true"; + case "date": { + if (raw === "") return null; + const value = new Date(raw); + return Number.isNaN(value.getTime()) ? null : value; + } + case "select": + return raw === "" ? null : raw; + case "multiSelect": + return parseDatabaseMultiSelectValue(raw); + default: + return raw; + } + } + + serializeCellValue(value: unknown, columnType: ColumnType): string { + if (value == null) return ""; + switch (columnType) { + case "checkbox": + return value ? "true" : "false"; + case "date": + return value instanceof Date ? value.toISOString() : String(value); + case "select": + return String(value); + case "multiSelect": + return Array.isArray(value) ? JSON.stringify(value) : ""; + default: + return String(value); + } + } + + validateCellValue(raw: string, columnType: ColumnType): string | null { + switch (columnType) { + case "number": + return raw !== "" && Number.isNaN(Number(raw)) ? "Invalid number" : null; + case "date": + return raw !== "" && Number.isNaN(new Date(raw).getTime()) ? "Invalid date" : null; + case "email": + return raw !== "" && !raw.includes("@") ? "Invalid email" : null; + case "url": + if (raw === "") return null; + try { + new URL(raw); + return null; + } catch { + return "Invalid URL"; + } + default: + return null; + } + } + + formatCellDisplay( + raw: string, + columnType: ColumnType, + format?: NumberFormat | DateFormat, + options?: DatabaseColumnDef["options"], + ): string { + if (raw === "") return ""; + switch (columnType) { + case "number": { + const value = Number(raw); + if (Number.isNaN(value)) return raw; + const numberFormat = format as NumberFormat | undefined; + if (numberFormat?.style === "currency" && numberFormat.currency) { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: numberFormat.currency, + minimumFractionDigits: numberFormat.decimals, + maximumFractionDigits: numberFormat.decimals, + }).format(value); + } + if (numberFormat?.style === "percent") { + return new Intl.NumberFormat(undefined, { + style: "percent", + minimumFractionDigits: numberFormat.decimals, + maximumFractionDigits: numberFormat.decimals, + }).format(value); + } + if (numberFormat?.decimals != null) { + return value.toFixed(numberFormat.decimals); + } + return String(value); + } + case "date": { + const value = new Date(raw); + if (Number.isNaN(value.getTime())) return raw; + const dateFormat = format as DateFormat | undefined; + const options: Intl.DateTimeFormatOptions = { + dateStyle: dateFormat?.dateStyle ?? "medium", + }; + if (dateFormat?.includeTime) { + options.timeStyle = "short"; + } + return new Intl.DateTimeFormat(undefined, options).format(value); + } + case "checkbox": + return raw.toLowerCase() === "true" ? "✓" : ""; + case "select": + return formatStoredSelectValue(raw, options); + case "multiSelect": + return formatStoredMultiSelectValue(raw, options); + default: + return raw; + } + } + + coerceValue( + raw: string, + fromType: ColumnType, + toType: ColumnType, + options?: DatabaseColumnDef["options"], + ): string { + return coerceDatabaseValue(raw, fromType, toType, options); + } + + getRowId(row: DatabaseRow): string { + return row.id; + } + +} diff --git a/packages/extensions/database/src/engineFilters.ts b/packages/extensions/database/src/engineFilters.ts new file mode 100644 index 0000000..8b11d0d --- /dev/null +++ b/packages/extensions/database/src/engineFilters.ts @@ -0,0 +1,376 @@ +import { + formatStoredMultiSelectValue, + formatStoredSelectValue, + parseDatabaseMultiSelectValue, + resolveStoredSelectOption, +} from "@pen/types"; +import type { DatabaseEngine } from "./engineCore"; +import { + DatabaseEngine as DatabaseEngineClass, + VALID_COLUMN_TYPES, +} from "./engineCore"; +import type { + ColumnType, + DatabaseColumnDef, + DatabaseRow, + DatabaseViewModelColumn, + DatabaseViewState, + FacetBucket, + FilterCondition, + FilterGroup, + FilterOperator, +} from "./types"; + +DatabaseEngineClass.prototype.deriveViewColumns = function deriveViewColumns(this: DatabaseEngine, view: DatabaseViewState): DatabaseViewModelColumn[] { + const schema = this.deriveColumnSchema(); + const schemaById = new Map(schema.map((column, columnIndex) => [column.id, { column, columnIndex }])); + const columnOrder = view.columnOrder ?? schema.map((column) => column.id); + const visibleColumnIds = new Set( + view.visibleColumnIds ?? schema.filter((column) => !column.hidden).map((column) => column.id), + ); + const orderedIds = [ + ...columnOrder, + ...schema.map((column) => column.id).filter((columnId) => !columnOrder.includes(columnId)), + ]; + + const orderedColumns = orderedIds + .map((columnId) => schemaById.get(columnId)) + .filter((entry): entry is { column: DatabaseColumnDef; columnIndex: number } => entry != null) + .filter(({ column }) => !column.hidden && visibleColumnIds.has(column.id)) + .map(({ column, columnIndex }) => ({ + id: column.id, + title: column.title, + type: this.normalizeColumnType(column.type), + columnIndex, + width: column.width, + hidden: column.hidden, + pinned: column.pinned, + options: column.options, + format: column.format, + readonly: column.readonly, + })); + + const leftColumns = orderedColumns.filter((column) => column.pinned === "left"); + const centerColumns = orderedColumns.filter((column) => column.pinned == null); + const rightColumns = orderedColumns.filter((column) => column.pinned === "right"); + return [...leftColumns, ...centerColumns, ...rightColumns]; +} +; +DatabaseEngineClass.prototype.matchesFilterGroup = function matchesFilterGroup(this: DatabaseEngine, row: DatabaseRow, filterGroup: FilterGroup, columns: DatabaseViewModelColumn[]): boolean { + const results = filterGroup.conditions.map((condition) => + this.isFilterGroup(condition) + ? this.matchesFilterGroup(row, condition, columns) + : this.matchesFilterCondition(row, condition, columns), + ); + return filterGroup.operator === "or" ? results.some(Boolean) : results.every(Boolean); +} +; +DatabaseEngineClass.prototype.matchesFilterCondition = function matchesFilterCondition(this: DatabaseEngine, row: DatabaseRow, condition: FilterCondition, columns: DatabaseViewModelColumn[]): boolean { + const column = columns.find((entry) => entry.id === condition.columnId); + if (!column) return true; + return this.matchesOperator( + row.cells[condition.columnId] ?? "", + condition.operator, + condition.value, + column.type, + column.options, + ); +} +; +DatabaseEngineClass.prototype.matchesOperator = function matchesOperator(this: DatabaseEngine, + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], +): boolean { + const normalizedRawValue = + columnType === "select" + ? resolveStoredSelectOption(rawValue, options)?.id ?? rawValue + : rawValue; + if (columnType === "date") { + return this.matchesDateOperator(normalizedRawValue, operator, filterValue); + } + const lowerValue = normalizedRawValue.toLowerCase(); + switch (operator) { + case "is": + return lowerValue === String(filterValue ?? "").toLowerCase(); + case "is_not": + return lowerValue !== String(filterValue ?? "").toLowerCase(); + case "contains": + if (columnType === "multiSelect") { + return parseDatabaseMultiSelectValue(rawValue).includes( + String(filterValue ?? ""), + ); + } + return lowerValue.includes(String(filterValue ?? "").toLowerCase()); + case "not_contains": + if (columnType === "multiSelect") { + return !parseDatabaseMultiSelectValue(rawValue).includes( + String(filterValue ?? ""), + ); + } + return !lowerValue.includes(String(filterValue ?? "").toLowerCase()); + case "starts_with": + return lowerValue.startsWith(String(filterValue ?? "").toLowerCase()); + case "ends_with": + return lowerValue.endsWith(String(filterValue ?? "").toLowerCase()); + case "is_empty": + return rawValue === ""; + case "is_not_empty": + return rawValue !== ""; + case "is_checked": + return rawValue.toLowerCase() === "true"; + case "is_unchecked": + return rawValue.toLowerCase() !== "true"; + case "is_any_of": { + const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; + if (columnType === "multiSelect") { + const selectedValues = parseDatabaseMultiSelectValue(rawValue); + return values.some((value) => selectedValues.includes(value)); + } + return values.includes(normalizedRawValue); + } + case "is_none_of": { + const values = Array.isArray(filterValue) ? filterValue : [String(filterValue ?? "")]; + if (columnType === "multiSelect") { + const selectedValues = parseDatabaseMultiSelectValue(rawValue); + return values.every((value) => !selectedValues.includes(value)); + } + return !values.includes(normalizedRawValue); + } + case "=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) === 0; + case "!=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) !== 0; + case ">": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) > 0; + case "<": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) < 0; + case ">=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) >= 0; + case "<=": + return this.comparePrimitive(normalizedRawValue, String(filterValue ?? ""), columnType) <= 0; + default: + return true; + } +} +; +DatabaseEngineClass.prototype.matchesDateOperator = function matchesDateOperator(this: DatabaseEngine, + rawValue: string, + operator: FilterOperator, + filterValue: string | string[] | null, +): boolean { + if (operator === "is_empty") { + return rawValue === ""; + } + if (operator === "is_not_empty") { + return rawValue !== ""; + } + const rawDate = this.parseFilterDate(rawValue); + if (!rawDate) { + return false; + } + switch (operator) { + case "is": { + const targetDate = this.parseFilterDate(String(filterValue ?? "")); + return targetDate ? this.isSameCalendarDay(rawDate, targetDate) : false; + } + case "is_before": { + const targetDate = this.parseFilterDate(String(filterValue ?? "")); + return targetDate ? this.startOfCalendarDay(rawDate) < this.startOfCalendarDay(targetDate) : false; + } + case "is_after": { + const targetDate = this.parseFilterDate(String(filterValue ?? "")); + return targetDate ? this.startOfCalendarDay(rawDate) > this.startOfCalendarDay(targetDate) : false; + } + case "is_between": { + if (!Array.isArray(filterValue) || filterValue.length < 2) { + return false; + } + const startDate = this.parseFilterDate(filterValue[0] ?? ""); + const endDate = this.parseFilterDate(filterValue[1] ?? ""); + if (!startDate || !endDate) { + return false; + } + const rawTime = this.startOfCalendarDay(rawDate); + return rawTime >= this.startOfCalendarDay(startDate) && rawTime <= this.startOfCalendarDay(endDate); + } + case "is_relative": + return this.matchesRelativeDate(rawDate, String(filterValue ?? "")); + default: + return true; + } +} +; +DatabaseEngineClass.prototype.compareCellValues = function compareCellValues(this: DatabaseEngine, + left: string, + right: string, + columnType: ColumnType, + options?: DatabaseColumnDef["options"], +): number { + if (columnType === "select") { + return this.comparePrimitive( + formatStoredSelectValue(left, options), + formatStoredSelectValue(right, options), + "text", + ); + } + if (columnType === "multiSelect") { + return this.comparePrimitive( + formatStoredMultiSelectValue(left, options), + formatStoredMultiSelectValue(right, options), + "text", + ); + } + return this.comparePrimitive(left, right, columnType); +} +; +DatabaseEngineClass.prototype.comparePrimitive = function comparePrimitive(this: DatabaseEngine, left: string, right: string, columnType: ColumnType): number { + switch (columnType) { + case "number": + return (Number(left) || 0) - (Number(right) || 0); + case "date": + return (new Date(left).getTime() || 0) - (new Date(right).getTime() || 0); + case "checkbox": + return (left.toLowerCase() === "true" ? 1 : 0) - (right.toLowerCase() === "true" ? 1 : 0); + default: + return left.toLowerCase().localeCompare(right.toLowerCase()); + } +} +; +DatabaseEngineClass.prototype.parseFilterDate = function parseFilterDate(this: DatabaseEngine, raw: string): Date | null { + if (!raw) { + return null; + } + const value = new Date(raw); + return Number.isNaN(value.getTime()) ? null : value; +} +; +DatabaseEngineClass.prototype.startOfCalendarDay = function startOfCalendarDay(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +} +; +DatabaseEngineClass.prototype.endOfCalendarDay = function endOfCalendarDay(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999).getTime(); +} +; +DatabaseEngineClass.prototype.startOfCalendarWeek = function startOfCalendarWeek(this: DatabaseEngine, value: Date): number { + const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); + nextValue.setDate(nextValue.getDate() - nextValue.getDay()); + return nextValue.getTime(); +} +; +DatabaseEngineClass.prototype.endOfCalendarWeek = function endOfCalendarWeek(this: DatabaseEngine, value: Date): number { + const nextValue = new Date(value.getFullYear(), value.getMonth(), value.getDate()); + nextValue.setDate(nextValue.getDate() + (6 - nextValue.getDay())); + return this.endOfCalendarDay(nextValue); +} +; +DatabaseEngineClass.prototype.startOfCalendarMonth = function startOfCalendarMonth(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth(), 1).getTime(); +} +; +DatabaseEngineClass.prototype.endOfCalendarMonth = function endOfCalendarMonth(this: DatabaseEngine, value: Date): number { + return new Date(value.getFullYear(), value.getMonth() + 1, 0, 23, 59, 59, 999).getTime(); +} +; +DatabaseEngineClass.prototype.isSameCalendarDay = function isSameCalendarDay(this: DatabaseEngine, left: Date, right: Date): boolean { + return left.getFullYear() === right.getFullYear() + && left.getMonth() === right.getMonth() + && left.getDate() === right.getDate(); +} +; +DatabaseEngineClass.prototype.matchesRelativeDate = function matchesRelativeDate(this: DatabaseEngine, rawDate: Date, relativeValue: string): boolean { + const now = new Date(); + const rawTime = rawDate.getTime(); + const todayStart = this.startOfCalendarDay(now); + switch (relativeValue) { + case "today": + return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(now); + case "yesterday": { + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + return rawTime >= this.startOfCalendarDay(yesterday) && rawTime <= this.endOfCalendarDay(yesterday); + } + case "tomorrow": { + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + return rawTime >= this.startOfCalendarDay(tomorrow) && rawTime <= this.endOfCalendarDay(tomorrow); + } + case "this_week": + return rawTime >= this.startOfCalendarWeek(now) && rawTime <= this.endOfCalendarWeek(now); + case "last_7_days": { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6); + return rawTime >= this.startOfCalendarDay(start) && rawTime <= this.endOfCalendarDay(now); + } + case "next_7_days": { + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 6); + return rawTime >= todayStart && rawTime <= this.endOfCalendarDay(end); + } + case "this_month": + return rawTime >= this.startOfCalendarMonth(now) && rawTime <= this.endOfCalendarMonth(now); + default: + return false; + } +} +; +DatabaseEngineClass.prototype.incrementFacetBucket = function incrementFacetBucket(this: DatabaseEngine, + buckets: Map, + value: string, + label: string, +): void { + const existing = buckets.get(value); + if (existing) { + existing.count += 1; + return; + } + buckets.set(value, { value, label, count: 1 }); +} +; +DatabaseEngineClass.prototype.formatGroupLabel = function formatGroupLabel(this: DatabaseEngine, + raw: string, + column: DatabaseViewModelColumn, +): string { + const formatted = this.formatCellDisplay( + raw, + column.type, + column.format, + column.options, + ); + return formatted || "(empty)"; +} +; +DatabaseEngineClass.prototype.resolveGroupingColumn = function resolveGroupingColumn(this: DatabaseEngine, + groupBy: string, + columns: DatabaseViewModelColumn[], +): DatabaseViewModelColumn | null { + const visibleColumn = columns.find((entry) => entry.id === groupBy); + if (visibleColumn) { + return visibleColumn; + } + const schema = this.deriveColumnSchema(); + const schemaColumn = schema.find((entry) => entry.id === groupBy); + if (!schemaColumn) { + return null; + } + return { + id: schemaColumn.id, + title: schemaColumn.title, + type: this.normalizeColumnType(schemaColumn.type), + columnIndex: schema.findIndex((entry) => entry.id === groupBy), + width: schemaColumn.width, + hidden: schemaColumn.hidden, + pinned: schemaColumn.pinned, + options: schemaColumn.options, + format: schemaColumn.format, + readonly: schemaColumn.readonly, + }; +} +; +DatabaseEngineClass.prototype.normalizeColumnType = function normalizeColumnType(this: DatabaseEngine, type: string | undefined): ColumnType { + return type && VALID_COLUMN_TYPES.has(type as ColumnType) ? (type as ColumnType) : "text"; +} +; +DatabaseEngineClass.prototype.isFilterGroup = function isFilterGroup(this: DatabaseEngine, value: FilterCondition | FilterGroup): value is FilterGroup { + return "conditions" in value; +} +; diff --git a/packages/extensions/database/src/engineRows.ts b/packages/extensions/database/src/engineRows.ts new file mode 100644 index 0000000..b2fa729 --- /dev/null +++ b/packages/extensions/database/src/engineRows.ts @@ -0,0 +1,151 @@ +import { + parseDatabaseMultiSelectValue, + resolveStoredSelectOption, +} from "@pen/types"; +import type { DatabaseEngine } from "./engineCore"; +import { DatabaseEngine as DatabaseEngineClass } from "./engineCore"; +import type { + ColumnType, + DatabaseColumnDef, + DatabaseRow, + DatabaseRowGroup, + DatabaseRowPinning, + DatabaseSort, + DatabaseViewModelColumn, + DatabaseViewModelRow, + FacetBucket, + FilterGroup, +} from "./types"; + +DatabaseEngineClass.prototype.filterRows = function filterRows(this: DatabaseEngine, rows: DatabaseRow[], filter: FilterGroup | null, columns: DatabaseViewModelColumn[]): DatabaseRow[] { + if (!filter || filter.conditions.length === 0) return rows; + return rows.filter((row) => this.matchesFilterGroup(row, filter, columns)); +} +; +DatabaseEngineClass.prototype.sortRows = function sortRows(this: DatabaseEngine, rows: DatabaseRow[], sorts: DatabaseSort[], columns: DatabaseViewModelColumn[]): DatabaseRow[] { + if (sorts.length === 0) return rows; + const columnMap = new Map(columns.map((column) => [column.id, column])); + return [...rows].sort((left, right) => { + for (const sort of sorts) { + const column = columnMap.get(sort.columnId); + if (!column) continue; + const compare = this.compareCellValues( + left.cells[sort.columnId] ?? "", + right.cells[sort.columnId] ?? "", + column.type, + column.options, + ); + if (compare !== 0) { + return sort.direction === "desc" ? -compare : compare; + } + } + return left.crdtRowIndex - right.crdtRowIndex; + }); +} +; +DatabaseEngineClass.prototype.paginateRows = function paginateRows(this: DatabaseEngine, rows: DatabaseRow[], pageIndex: number, pageSize: number): DatabaseViewModelRow[] { + const normalizedPageSize = Math.max(1, pageSize); + const normalizedPageIndex = Math.max(0, pageIndex); + const start = normalizedPageIndex * normalizedPageSize; + return rows.slice(start, start + normalizedPageSize); +} +; +DatabaseEngineClass.prototype.splitPinnedRows = function splitPinnedRows(this: DatabaseEngine, + rows: DatabaseRow[], + rowPinning?: DatabaseRowPinning, +): { + top: DatabaseViewModelRow[]; + rows: DatabaseViewModelRow[]; + bottom: DatabaseViewModelRow[]; +} { + const rowMap = new Map(rows.map((row) => [row.id, row])); + const topRowIds: string[] = [...(rowPinning?.top ?? [])]; + const bottomRowIds: string[] = [...(rowPinning?.bottom ?? [])]; + const top = topRowIds + .map((rowId: string) => rowMap.get(rowId)) + .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); + const bottom = bottomRowIds + .map((rowId: string) => rowMap.get(rowId)) + .filter((row: DatabaseRow | undefined): row is DatabaseViewModelRow => row != null); + const pinnedIds = new Set([...top, ...bottom].map((row) => row.id)); + return { + top, + rows: rows.filter((row) => !pinnedIds.has(row.id)), + bottom, + }; +} +; +DatabaseEngineClass.prototype.groupRows = function groupRows(this: DatabaseEngine, + rows: DatabaseViewModelRow[], + groupBy: string | null, + columns: DatabaseViewModelColumn[], +): DatabaseRowGroup[] { + if (!groupBy) { + return []; + } + const column = this.resolveGroupingColumn(groupBy, columns); + if (!column) { + return []; + } + const groups: DatabaseRowGroup[] = []; + const groupByKey = new Map(); + for (const row of rows) { + const label = this.formatGroupLabel(row.cells[column.id] ?? "", column); + const key = `${column.id}:${label}`; + const existing = groupByKey.get(key); + if (existing) { + existing.rows.push(row); + continue; + } + const nextGroup: DatabaseRowGroup = { + key, + label, + rows: [row], + }; + groupByKey.set(key, nextGroup); + groups.push(nextGroup); + } + return groups; +} +; +DatabaseEngineClass.prototype.facetColumnValues = function facetColumnValues(this: DatabaseEngine, + rows: DatabaseRow[], + columnId: string, + columns: DatabaseViewModelColumn[], +): FacetBucket[] { + const column = columns.find((entry) => entry.id === columnId); + if (!column) return []; + const buckets = new Map(); + for (const row of rows) { + const raw = row.cells[columnId] ?? ""; + if (!raw) continue; + if (column.type === "multiSelect") { + const values = parseDatabaseMultiSelectValue(raw); + for (const value of values) { + const option = resolveStoredSelectOption(value, column.options); + const bucketValue = option?.id ?? value; + const bucketLabel = option?.value ?? value; + this.incrementFacetBucket(buckets, bucketValue, bucketLabel); + } + continue; + } + if (column.type === "select") { + const option = resolveStoredSelectOption(raw, column.options); + const bucketValue = option?.id ?? raw; + const bucketLabel = option?.value ?? raw; + this.incrementFacetBucket(buckets, bucketValue, bucketLabel); + continue; + } + if (column.type === "checkbox") { + const bucketValue = raw.toLowerCase() === "true" ? "true" : "false"; + const bucketLabel = bucketValue === "true" ? "Checked" : "Unchecked"; + this.incrementFacetBucket(buckets, bucketValue, bucketLabel); + continue; + } + this.incrementFacetBucket(buckets, raw, raw); + } + return [...buckets.values()].sort((left, right) => + left.label.toLowerCase().localeCompare(right.label.toLowerCase()), + ); +} +; diff --git a/packages/extensions/database/src/rendererColumnMenu.tsx b/packages/extensions/database/src/rendererColumnMenu.tsx new file mode 100644 index 0000000..5bfd63f --- /dev/null +++ b/packages/extensions/database/src/rendererColumnMenu.tsx @@ -0,0 +1,273 @@ +import { DATA_ATTRS } from "@pen/react"; +import { useEffect, useState } from "react"; +import type { ColumnType, DatabaseColumnDef } from "./types"; + +const COLUMN_TYPES: ColumnType[] = [ + "text", + "number", + "checkbox", + "select", + "multiSelect", + "date", + "url", + "email", + "relation", +]; + +const OPTION_COLOR_CHOICES = [ + "gray", + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "pink", +] as const; +export function ColumnMenu(props: { + column: DatabaseColumnDef | undefined; + onClose: () => void; + onRename: (title: string) => void; + onChangeType: (type: ColumnType) => void; + onDelete: () => void; + onToggleVisibility: () => void; + onChangePin: (nextPinned: "left" | "right" | undefined) => void; + onAddOption: (value: string, color?: string) => void; + onRenameOption: (optionId: string, value: string) => void; + onRecolorOption: (optionId: string, color: string) => void; + onRemoveOption: (optionId: string) => void; + onMoveOption: (optionId: string, direction: "up" | "down") => void; +}) { + const { + column, + onClose, + onRename, + onChangeType, + onDelete, + onToggleVisibility, + onChangePin, + onAddOption, + onRenameOption, + onRecolorOption, + onRemoveOption, + onMoveOption, + } = props; + + const [renameValue, setRenameValue] = useState(column?.title ?? ""); + const [showTypeMenu, setShowTypeMenu] = useState(false); + const [newOptionValue, setNewOptionValue] = useState(""); + const [newOptionColor, setNewOptionColor] = useState("gray"); + + const typeItems = COLUMN_TYPES.map((type) => ( + + )); + const typeMenu = showTypeMenu ? ( +
{typeItems}
+ ) : null; + const supportsOptionEditing = + column?.type === "select" || column?.type === "multiSelect"; + const pinMenuButtons = ( +
+ + + +
+ ); + const optionColorItems = OPTION_COLOR_CHOICES.map((color) => ( + + )); + const optionEditorRows = (column?.options ?? []).map((option, index, options) => ( + 0} + canMoveDown={index < options.length - 1} + onRename={(value) => onRenameOption(option.id, value)} + onRecolor={(color) => onRecolorOption(option.id, color)} + onRemove={() => onRemoveOption(option.id)} + onMoveUp={() => onMoveOption(option.id, "up")} + onMoveDown={() => onMoveOption(option.id, "down")} + /> + )); + const optionEditor = supportsOptionEditing ? ( +
+
Options
+ {optionEditorRows} +
+ setNewOptionValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onAddOption(newOptionValue, newOptionColor); + setNewOptionValue(""); + } + }} + /> + + +
+
+ ) : null; + + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
+ setRenameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onRename(renameValue); + } + if (event.key === "Escape") { + onClose(); + } + }} + autoFocus + /> +
+
+ + {typeMenu} + {column?.type === "formula" ? ( +
+ Formula columns are read-only until the evaluator lands. +
+ ) : null} +
+ {optionEditor} +
+ +
+ {pinMenuButtons} +
+ +
+
+ +
+
+ ); +} + +function OptionEditorRow(props: { + option: NonNullable[number]; + colorItems: React.ReactElement[]; + canMoveUp: boolean; + canMoveDown: boolean; + onRename: (value: string) => void; + onRecolor: (color: string) => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +}) { + const { + option, + colorItems, + canMoveUp, + canMoveDown, + onRename, + onRecolor, + onRemove, + onMoveUp, + onMoveDown, + } = props; + + const [value, setValue] = useState(option.value); + + useEffect(() => { + setValue(option.value); + }, [option.id, option.value]); + + return ( +
+ setValue(event.target.value)} + onBlur={() => onRename(value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onRename(value); + } + }} + /> + + + + +
+ ); +} diff --git a/packages/extensions/database/src/rendererFilterPanel.tsx b/packages/extensions/database/src/rendererFilterPanel.tsx new file mode 100644 index 0000000..3c7c32b --- /dev/null +++ b/packages/extensions/database/src/rendererFilterPanel.tsx @@ -0,0 +1,374 @@ +import { DATA_ATTRS } from "@pen/react"; +import { useEffect, useState } from "react"; +import type { + DatabaseColumnDef, + FacetBucket, + FilterCondition, + FilterGroup, + FilterOperator, +} from "./types"; +import { + addFilterNodeAtPath, + createDefaultFilterCondition, + DATE_RELATIVE_FILTER_OPTIONS, + dateFilterNeedsValue, + defaultOperatorFor, + getDateFilterRangeValue, + getDateFilterSingleValue, + getDefaultFilterValue, + getDefaultFilterValueForOperator, + getFilterPathKey, + operatorNeedsValue, + operatorOptionsFor, + removeFilterNodeAtPath, + updateFilterConditionAtPath, + updateFilterGroupOperatorAtPath, +} from "./utils/databaseRenderer"; + +type FilterNode = FilterCondition | FilterGroup; +type FilterPath = number[]; +export function FilterPanel(props: { + columnSchema: DatabaseColumnDef[]; + filterGroup: FilterGroup; + facetBucketsByColumnId: Record; + onChange: (filter: FilterGroup | null) => void; + onClose: () => void; +}) { + const { columnSchema, filterGroup, facetBucketsByColumnId, onChange, onClose } = + props; + + const rootEditor = ( + + ); + + return ( +
+
+ Filters + +
+ {rootEditor} +
+ ); +} + +function FilterGroupEditor(props: { + columnSchema: DatabaseColumnDef[]; + facetBucketsByColumnId: Record; + rootFilterGroup: FilterGroup; + group: FilterGroup; + groupPath: FilterPath; + isRoot?: boolean; + onChange: (filter: FilterGroup | null) => void; +}) { + const { + columnSchema, + facetBucketsByColumnId, + rootFilterGroup, + group, + groupPath, + isRoot = false, + onChange, + } = props; + + const groupPathKey = getFilterPathKey(groupPath); + + function handleGroupOperatorChange(operator: FilterGroup["operator"]) { + const nextFilter = updateFilterGroupOperatorAtPath( + rootFilterGroup, + groupPath, + operator, + ); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + function handleAddCondition() { + const nextFilter = addFilterNodeAtPath( + rootFilterGroup, + groupPath, + createDefaultFilterCondition(columnSchema), + ); + onChange(nextFilter); + } + + function handleAddGroup() { + const nextFilter = addFilterNodeAtPath(rootFilterGroup, groupPath, { + operator: "and", + conditions: [createDefaultFilterCondition(columnSchema)], + }); + onChange(nextFilter); + } + + function handleRemoveGroup() { + if (groupPath.length === 0) { + onChange(null); + return; + } + const nextFilter = removeFilterNodeAtPath(rootFilterGroup, groupPath); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + const childItems = group.conditions.map((condition, index) => { + const childPath = [...groupPath, index]; + if (isFilterGroupNode(condition)) { + return ( + + ); + } + return ( + + ); + }); + + return ( +
+
+ + {!isRoot ? ( + + ) : null} +
+ {childItems} +
+ + +
+
+ ); +} + +function FilterConditionRow(props: { + columnSchema: DatabaseColumnDef[]; + condition: FilterCondition; + conditionPath: FilterPath; + facetBucketsByColumnId: Record; + rootFilterGroup: FilterGroup; + onChange: (filter: FilterGroup | null) => void; +}) { + const { + columnSchema, + condition, + conditionPath, + facetBucketsByColumnId, + rootFilterGroup, + onChange, + } = props; + + const conditionPathKey = getFilterPathKey(conditionPath); + const column = + columnSchema.find((entry) => entry.id === condition.columnId) ?? columnSchema[0]; + const operatorOptions = operatorOptionsFor(column?.type ?? "text"); + const facetBuckets = facetBucketsByColumnId[condition.columnId] ?? []; + const datalistId = `pen-db-filter-values-${conditionPathKey}`; + + const columnOptionItems = columnSchema.map((columnItem) => ( + + )); + const operatorOptionItems = operatorOptions.map((option) => ( + + )); + const facetOptionItems = facetBuckets.map((bucket) => ( + + )); + + function handleUpdateCondition(patch: Partial) { + const nextFilter = updateFilterConditionAtPath( + rootFilterGroup, + conditionPath, + patch, + ); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + function handleRemoveCondition() { + const nextFilter = removeFilterNodeAtPath(rootFilterGroup, conditionPath); + onChange(nextFilter.conditions.length > 0 ? nextFilter : null); + } + + function handleDateRangeChange(index: 0 | 1, nextValue: string) { + const currentValue = Array.isArray(condition.value) + ? condition.value + : ["", ""]; + const nextRangeValue: string[] = [...currentValue]; + nextRangeValue[index] = nextValue; + handleUpdateCondition({ value: nextRangeValue }); + } + + const checkboxValueControl = ( + + ); + const relativeOptionItems = DATE_RELATIVE_FILTER_OPTIONS.map((option) => ( + + )); + const dateValueControl = !dateFilterNeedsValue(condition.operator) ? null : condition.operator === "is_relative" ? ( + + ) : condition.operator === "is_between" ? ( +
+ handleDateRangeChange(0, event.target.value)} + /> + to + handleDateRangeChange(1, event.target.value)} + /> +
+ ) : ( + handleUpdateCondition({ value: event.target.value })} + /> + ); + const textValueControl = ( + <> + 0 ? datalistId : undefined} + value={typeof condition.value === "string" ? condition.value : ""} + onChange={(event) => handleUpdateCondition({ value: event.target.value })} + placeholder="Filter value…" + /> + {facetBuckets.length > 0 ? ( + {facetOptionItems} + ) : null} + + ); + const valueControl = + column?.type === "checkbox" + ? checkboxValueControl + : column?.type === "date" + ? dateValueControl + : operatorNeedsValue(condition.operator) + ? textValueControl + : null; + + return ( +
+ + + {valueControl} + +
+ ); +} +function isFilterGroupNode(value: FilterNode): value is FilterGroup { + return "conditions" in value; +} diff --git a/packages/extensions/database/src/rendererPanels.tsx b/packages/extensions/database/src/rendererPanels.tsx index dfe1b5b..09375f5 100644 --- a/packages/extensions/database/src/rendererPanels.tsx +++ b/packages/extensions/database/src/rendererPanels.tsx @@ -1,305 +1,10 @@ import { DATA_ATTRS } from "@pen/react"; -import React, { useEffect, useState } from "react"; -import type { - ColumnType, - DatabaseColumnDef, - DatabaseViewState, - FacetBucket, - FilterCondition, - FilterGroup, - FilterOperator, -} from "./types"; -import { - addFilterNodeAtPath, - createDefaultFilterCondition, - DATE_RELATIVE_FILTER_OPTIONS, - dateFilterNeedsValue, - defaultOperatorFor, - getDateFilterRangeValue, - getDateFilterSingleValue, - getDefaultFilterValue, - getDefaultFilterValueForOperator, - getFilterPathKey, - operatorNeedsValue, - operatorOptionsFor, - removeFilterNodeAtPath, - updateFilterConditionAtPath, - updateFilterGroupOperatorAtPath, -} from "./utils/databaseRenderer"; +import type { DatabaseColumnDef, DatabaseViewState } from "./types"; -const COLUMN_TYPES: ColumnType[] = [ - "text", - "number", - "checkbox", - "select", - "multiSelect", - "date", - "url", - "email", - "relation", -]; +export { ColumnMenu } from "./rendererColumnMenu"; +export { FilterPanel } from "./rendererFilterPanel"; -const OPTION_COLOR_CHOICES = [ - "gray", - "red", - "orange", - "yellow", - "green", - "blue", - "purple", - "pink", -] as const; -type FilterNode = FilterCondition | FilterGroup; -type FilterPath = number[]; - -export function ColumnMenu(props: { - column: DatabaseColumnDef | undefined; - onClose: () => void; - onRename: (title: string) => void; - onChangeType: (type: ColumnType) => void; - onDelete: () => void; - onToggleVisibility: () => void; - onChangePin: (nextPinned: "left" | "right" | undefined) => void; - onAddOption: (value: string, color?: string) => void; - onRenameOption: (optionId: string, value: string) => void; - onRecolorOption: (optionId: string, color: string) => void; - onRemoveOption: (optionId: string) => void; - onMoveOption: (optionId: string, direction: "up" | "down") => void; -}) { - const { - column, - onClose, - onRename, - onChangeType, - onDelete, - onToggleVisibility, - onChangePin, - onAddOption, - onRenameOption, - onRecolorOption, - onRemoveOption, - onMoveOption, - } = props; - - const [renameValue, setRenameValue] = useState(column?.title ?? ""); - const [showTypeMenu, setShowTypeMenu] = useState(false); - const [newOptionValue, setNewOptionValue] = useState(""); - const [newOptionColor, setNewOptionColor] = useState("gray"); - - const typeItems = COLUMN_TYPES.map((type) => ( - - )); - const typeMenu = showTypeMenu ? ( -
{typeItems}
- ) : null; - const supportsOptionEditing = - column?.type === "select" || column?.type === "multiSelect"; - const pinMenuButtons = ( -
- - - -
- ); - const optionColorItems = OPTION_COLOR_CHOICES.map((color) => ( - - )); - const optionEditorRows = (column?.options ?? []).map((option, index, options) => ( - 0} - canMoveDown={index < options.length - 1} - onRename={(value) => onRenameOption(option.id, value)} - onRecolor={(color) => onRecolorOption(option.id, color)} - onRemove={() => onRemoveOption(option.id)} - onMoveUp={() => onMoveOption(option.id, "up")} - onMoveDown={() => onMoveOption(option.id, "down")} - /> - )); - const optionEditor = supportsOptionEditing ? ( -
-
Options
- {optionEditorRows} -
- setNewOptionValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - onAddOption(newOptionValue, newOptionColor); - setNewOptionValue(""); - } - }} - /> - - -
-
- ) : null; - - return ( -
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > -
- setRenameValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - onRename(renameValue); - } - if (event.key === "Escape") { - onClose(); - } - }} - autoFocus - /> -
-
- - {typeMenu} - {column?.type === "formula" ? ( -
- Formula columns are read-only until the evaluator lands. -
- ) : null} -
- {optionEditor} -
- -
- {pinMenuButtons} -
- -
-
- -
-
- ); -} - -function OptionEditorRow(props: { - option: NonNullable[number]; - colorItems: React.ReactElement[]; - canMoveUp: boolean; - canMoveDown: boolean; - onRename: (value: string) => void; - onRecolor: (color: string) => void; - onRemove: () => void; - onMoveUp: () => void; - onMoveDown: () => void; -}) { - const { - option, - colorItems, - canMoveUp, - canMoveDown, - onRename, - onRecolor, - onRemove, - onMoveUp, - onMoveDown, - } = props; - - const [value, setValue] = useState(option.value); - - useEffect(() => { - setValue(option.value); - }, [option.id, option.value]); - - return ( -
- setValue(event.target.value)} - onBlur={() => onRename(value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - onRename(value); - } - }} - /> - - - - -
- ); -} export function SortPanel(props: { columnSchema: DatabaseColumnDef[]; @@ -410,348 +115,6 @@ export function SortPanel(props: { ); } -export function FilterPanel(props: { - columnSchema: DatabaseColumnDef[]; - filterGroup: FilterGroup; - facetBucketsByColumnId: Record; - onChange: (filter: FilterGroup | null) => void; - onClose: () => void; -}) { - const { columnSchema, filterGroup, facetBucketsByColumnId, onChange, onClose } = - props; - - const rootEditor = ( - - ); - - return ( -
-
- Filters - -
- {rootEditor} -
- ); -} - -function FilterGroupEditor(props: { - columnSchema: DatabaseColumnDef[]; - facetBucketsByColumnId: Record; - rootFilterGroup: FilterGroup; - group: FilterGroup; - groupPath: FilterPath; - isRoot?: boolean; - onChange: (filter: FilterGroup | null) => void; -}) { - const { - columnSchema, - facetBucketsByColumnId, - rootFilterGroup, - group, - groupPath, - isRoot = false, - onChange, - } = props; - - const groupPathKey = getFilterPathKey(groupPath); - - function handleGroupOperatorChange(operator: FilterGroup["operator"]) { - const nextFilter = updateFilterGroupOperatorAtPath( - rootFilterGroup, - groupPath, - operator, - ); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - function handleAddCondition() { - const nextFilter = addFilterNodeAtPath( - rootFilterGroup, - groupPath, - createDefaultFilterCondition(columnSchema), - ); - onChange(nextFilter); - } - - function handleAddGroup() { - const nextFilter = addFilterNodeAtPath(rootFilterGroup, groupPath, { - operator: "and", - conditions: [createDefaultFilterCondition(columnSchema)], - }); - onChange(nextFilter); - } - - function handleRemoveGroup() { - if (groupPath.length === 0) { - onChange(null); - return; - } - const nextFilter = removeFilterNodeAtPath(rootFilterGroup, groupPath); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - const childItems = group.conditions.map((condition, index) => { - const childPath = [...groupPath, index]; - if (isFilterGroupNode(condition)) { - return ( - - ); - } - return ( - - ); - }); - - return ( -
-
- - {!isRoot ? ( - - ) : null} -
- {childItems} -
- - -
-
- ); -} - -function FilterConditionRow(props: { - columnSchema: DatabaseColumnDef[]; - condition: FilterCondition; - conditionPath: FilterPath; - facetBucketsByColumnId: Record; - rootFilterGroup: FilterGroup; - onChange: (filter: FilterGroup | null) => void; -}) { - const { - columnSchema, - condition, - conditionPath, - facetBucketsByColumnId, - rootFilterGroup, - onChange, - } = props; - - const conditionPathKey = getFilterPathKey(conditionPath); - const column = - columnSchema.find((entry) => entry.id === condition.columnId) ?? columnSchema[0]; - const operatorOptions = operatorOptionsFor(column?.type ?? "text"); - const facetBuckets = facetBucketsByColumnId[condition.columnId] ?? []; - const datalistId = `pen-db-filter-values-${conditionPathKey}`; - - const columnOptionItems = columnSchema.map((columnItem) => ( - - )); - const operatorOptionItems = operatorOptions.map((option) => ( - - )); - const facetOptionItems = facetBuckets.map((bucket) => ( - - )); - - function handleUpdateCondition(patch: Partial) { - const nextFilter = updateFilterConditionAtPath( - rootFilterGroup, - conditionPath, - patch, - ); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - function handleRemoveCondition() { - const nextFilter = removeFilterNodeAtPath(rootFilterGroup, conditionPath); - onChange(nextFilter.conditions.length > 0 ? nextFilter : null); - } - - function handleDateRangeChange(index: 0 | 1, nextValue: string) { - const currentValue = Array.isArray(condition.value) - ? condition.value - : ["", ""]; - const nextRangeValue: string[] = [...currentValue]; - nextRangeValue[index] = nextValue; - handleUpdateCondition({ value: nextRangeValue }); - } - - const checkboxValueControl = ( - - ); - const relativeOptionItems = DATE_RELATIVE_FILTER_OPTIONS.map((option) => ( - - )); - const dateValueControl = !dateFilterNeedsValue(condition.operator) ? null : condition.operator === "is_relative" ? ( - - ) : condition.operator === "is_between" ? ( -
- handleDateRangeChange(0, event.target.value)} - /> - to - handleDateRangeChange(1, event.target.value)} - /> -
- ) : ( - handleUpdateCondition({ value: event.target.value })} - /> - ); - const textValueControl = ( - <> - 0 ? datalistId : undefined} - value={typeof condition.value === "string" ? condition.value : ""} - onChange={(event) => handleUpdateCondition({ value: event.target.value })} - placeholder="Filter value…" - /> - {facetBuckets.length > 0 ? ( - {facetOptionItems} - ) : null} - - ); - const valueControl = - column?.type === "checkbox" - ? checkboxValueControl - : column?.type === "date" - ? dateValueControl - : operatorNeedsValue(condition.operator) - ? textValueControl - : null; - - return ( -
- - - {valueControl} - -
- ); -} export function ColumnVisibilityPanel(props: { columnSchema: DatabaseColumnDef[]; @@ -815,6 +178,3 @@ export function GroupPanel(props: { ); } -function isFilterGroupNode(value: FilterNode): value is FilterGroup { - return "conditions" in value; -} diff --git a/packages/extensions/database/src/rendererViewTypes.ts b/packages/extensions/database/src/rendererViewTypes.ts new file mode 100644 index 0000000..aafb27f --- /dev/null +++ b/packages/extensions/database/src/rendererViewTypes.ts @@ -0,0 +1,64 @@ +import type React from "react"; +import type { + DatabaseColumnDef, + DatabaseRow, + DatabaseRowGroup, + DatabaseViewModelColumn, + DatabaseViewModelRow, + DatabaseViewState, +} from "./types"; +import type { ColumnStickyStyle } from "./utils/databaseRenderer"; + +export type RowSectionOptions = { + sectionLabel?: string; +}; + +export type CellPointerHandler = ( + event: React.MouseEvent, + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, +) => void; + +export type DatabaseViewBodyProps = { + blockId: string; + viewType: DatabaseViewState["type"]; + ctxSelected: boolean | undefined; + headerRow: React.ReactElement; + tableColumnSpan: number; + columns: DatabaseViewModelColumn[]; + allRows: DatabaseRow[]; + rows: DatabaseViewModelRow[]; + pinnedTopRows: DatabaseViewModelRow[]; + pinnedBottomRows: DatabaseViewModelRow[]; + rowGroups: DatabaseRowGroup[]; + rowSelection: Record; + showRowSelectionControls: boolean; + isDataReadonly: boolean; + isRemote: boolean; + defaultColumnWidth: number; + pinnedOffsets: Record; + getColumnStickyStyle: ( + column: DatabaseViewModelColumn, + pinnedOffsets: Record, + defaultColumnWidth: number, + section: "header" | "body", + ) => ColumnStickyStyle; + isCellSelected: (row: number, column: number) => boolean; + formatRemoteCell: ( + row: DatabaseViewModelRow, + column: DatabaseViewModelColumn, + ) => string; + onToggleRow: (rowId: string) => void; + onRowSelectionKeyDown: ( + event: React.KeyboardEvent, + rowId: string, + ) => void; + onCellMouseDown: CellPointerHandler; + onCellDoubleClick: CellPointerHandler; + addListRow: React.ReactNode; + addRowControl: React.ReactNode; + addColumnControl: React.ReactNode; + calendarMonth: Date; + onShiftCalendarMonth: (amount: number) => void; + calendarDateColumn: DatabaseColumnDef | undefined; +}; diff --git a/packages/extensions/database/src/rendererViews.tsx b/packages/extensions/database/src/rendererViews.tsx index 6366f69..1118e9e 100644 --- a/packages/extensions/database/src/rendererViews.tsx +++ b/packages/extensions/database/src/rendererViews.tsx @@ -1,74 +1,18 @@ import { DATA_ATTRS } from "@pen/react"; import React from "react"; +import type { DatabaseViewBodyProps, RowSectionOptions } from "./rendererViewTypes"; import { DatabaseCellContent } from "./cellEditors"; import type { - DatabaseColumnDef, - DatabaseRow, - DatabaseRowGroup, DatabaseViewModelColumn, DatabaseViewModelRow, - DatabaseViewState, } from "./types"; -import type { ColumnStickyStyle } from "./utils/databaseRenderer"; import { buildCalendarMonthData, CALENDAR_WEEKDAY_LABELS, shiftMonth, } from "./utils/databaseRenderer"; -type RowSectionOptions = { - sectionLabel?: string; -}; - -type CellPointerHandler = ( - event: React.MouseEvent, - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, -) => void; - -export function DatabaseViewBody(props: { - blockId: string; - viewType: DatabaseViewState["type"]; - ctxSelected: boolean | undefined; - headerRow: React.ReactElement; - tableColumnSpan: number; - columns: DatabaseViewModelColumn[]; - allRows: DatabaseRow[]; - rows: DatabaseViewModelRow[]; - pinnedTopRows: DatabaseViewModelRow[]; - pinnedBottomRows: DatabaseViewModelRow[]; - rowGroups: DatabaseRowGroup[]; - rowSelection: Record; - showRowSelectionControls: boolean; - isDataReadonly: boolean; - isRemote: boolean; - defaultColumnWidth: number; - pinnedOffsets: Record; - getColumnStickyStyle: ( - column: DatabaseViewModelColumn, - pinnedOffsets: Record, - defaultColumnWidth: number, - section: "header" | "body", - ) => ColumnStickyStyle; - isCellSelected: (row: number, column: number) => boolean; - formatRemoteCell: ( - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, - ) => string; - onToggleRow: (rowId: string) => void; - onRowSelectionKeyDown: ( - event: React.KeyboardEvent, - rowId: string, - ) => void; - onCellMouseDown: CellPointerHandler; - onCellDoubleClick: CellPointerHandler; - addListRow: React.ReactNode; - addRowControl: React.ReactNode; - addColumnControl: React.ReactNode; - calendarMonth: Date; - onShiftCalendarMonth: (amount: number) => void; - calendarDateColumn: DatabaseColumnDef | undefined; -}) { +export function DatabaseViewBody(props: DatabaseViewBodyProps) { const { blockId, viewType, diff --git a/packages/extensions/database/src/useDatabaseController.ts b/packages/extensions/database/src/useDatabaseController.ts index a770d28..42fa269 100644 --- a/packages/extensions/database/src/useDatabaseController.ts +++ b/packages/extensions/database/src/useDatabaseController.ts @@ -1,4 +1,4 @@ -import type { BlockHandle, CellSelection } from "@pen/types"; +import type { CellSelection } from "@pen/types"; import { DATA_ATTRS, useEditorContext, @@ -6,9 +6,12 @@ import { useFieldEditorState, useSelection, } from "@pen/react"; -import { generateId } from "@pen/types"; import { useEffect, useMemo, useState } from "react"; import { DatabaseEngine } from "./engine"; +import { createDatabaseMutationHandlers } from "./databaseControllerMutationHandlers"; +import { createDatabaseSelectionHandlers } from "./databaseControllerSelectionHandlers"; +import type { DatabaseController, DatabaseControllerConfig } from "./databaseControllerTypes"; +export type { DatabaseController, DatabaseControllerConfig } from "./databaseControllerTypes"; import type { ColumnType, DatabaseColumnDef, @@ -21,7 +24,6 @@ import type { FacetBucket, FilterGroup, } from "./types"; -import { isCellInSelection } from "./utils"; import { createDatabaseViewDefinition, getCalendarDateColumn, @@ -84,118 +86,6 @@ function getOrCreateDatabaseRowSelectionController( return controller; } -export interface DatabaseControllerConfig { - blockId: string; -} - -export interface DatabaseController { - block: BlockHandle; - engine: DatabaseEngine; - viewModel: DatabaseViewModel; - columnSchema: DatabaseColumnDef[]; - - viewState: DatabaseViewState; - updateViewState: (patch: Partial>) => void; - views: readonly DatabaseViewState[]; - - title: string; - isEditingTitle: boolean; - setIsEditingTitle: (editing: boolean) => void; - handleTitleClick: () => void; - handleTitleBlur: (event: React.FocusEvent) => void; - handleTitleKeyDown: (event: React.KeyboardEvent) => void; - - addRow: () => void; - addColumn: () => void; - deleteColumn: (columnId: string) => void; - renameColumn: (columnId: string, title: string) => void; - changeColumnType: (columnId: string, type: ColumnType) => void; - toggleColumnVisibility: (columnId: string) => void; - changeColumnPin: (columnId: string, pinned: "left" | "right" | undefined) => void; - addOption: (columnId: string, value: string, color?: string) => void; - renameOption: (columnId: string, optionId: string, value: string) => void; - recolorOption: (columnId: string, optionId: string, color: string) => void; - removeOption: (columnId: string, optionId: string) => void; - moveOption: (columnId: string, optionId: string, direction: "up" | "down") => void; - - addView: (type: DatabaseViewState["type"]) => void; - setActiveView: (viewId: string) => void; - removeView: (viewId: string) => void; - showAddViewMenu: boolean; - setShowAddViewMenu: (show: boolean) => void; - - rowSelection: Record; - toggleRow: (rowId: string) => void; - toggleAllRows: () => void; - deleteSelectedRows: () => void; - pinSelectedRows: (target: "top" | "bottom" | "none") => void; - handleRowSelectionKeyDown: (event: React.KeyboardEvent, rowId: string) => void; - hasSelectedRows: boolean; - selectedRowCount: number; - allVisibleSelected: boolean; - - cellSelection: CellSelection | null; - createCellSelection: (anchor: { row: number; col: number }, head?: { row: number; col: number }) => CellSelection; - handleCellMouseDown: CellPointerHandler; - handleCellDoubleClick: CellPointerHandler; - - globalSearch: string; - setGlobalSearch: (value: string) => void; - - filterGroup: FilterGroup; - handleFilterGroupChange: (filter: FilterGroup | null) => void; - facetBucketsByColumnId: Record; - showFilterPanel: boolean; - setShowFilterPanel: (show: boolean) => void; - - handleSortChange: (sort: NonNullable) => void; - handleHeaderClick: (event: React.MouseEvent, columnId: string) => void; - showSortPanel: boolean; - setShowSortPanel: (show: boolean) => void; - - handleChangeGroupBy: (groupBy: string | null) => void; - showGroupPanel: boolean; - setShowGroupPanel: (show: boolean) => void; - - showColumnVisibilityMenu: boolean; - setShowColumnVisibilityMenu: (show: boolean) => void; - - activeColumnMenu: string | null; - setActiveColumnMenu: (columnId: string | null) => void; - - handlePreviousPage: () => void; - handleNextPage: () => void; - pageCount: number; - showPagination: boolean; - - remoteLoading: boolean; - remoteError: string | null; - - isUiReadonly: boolean; - isDataReadonly: boolean; - showRowSelectionControls: boolean; - - columns: DatabaseViewModelColumn[]; - allRows: DatabaseViewModelRow[]; - rows: DatabaseViewModelRow[]; - pinnedTopRows: DatabaseViewModelRow[]; - pinnedBottomRows: DatabaseViewModelRow[]; - rowGroups: DatabaseViewModel["rowGroups"]; - visibleRows: DatabaseViewModelRow[]; - visibleColumnIds: string[]; - visibleColumnIdSet: ReadonlySet; - - defaultColumnWidth: number; - pinnedOffsets: Record; - getColumnStickyStyle: typeof getColumnStickyStyle; - getFixedEdgeStyle: typeof getFixedEdgeStyle; - isCellSelected: (row: number, column: number) => boolean; - formatRemoteCell: (row: DatabaseViewModelRow, column: DatabaseViewModelColumn) => string; - - calendarMonth: Date; - shiftCalendarMonth: (amount: number) => void; - calendarDateColumn: DatabaseColumnDef | undefined; -} export function useDatabaseController(config: DatabaseControllerConfig): DatabaseController { const { blockId } = config; @@ -291,561 +181,100 @@ export function useDatabaseController(config: DatabaseControllerConfig): Databas // --- Cell selection helpers --- - function createDatabaseCellSelection( - anchor: { row: number; col: number }, - head: { row: number; col: number } = anchor, - ): CellSelection { - return { - type: "cell", - blockId, - anchor, - head, - rowIds: visibleRowIds, - columnIds: visibleSelectionColumnIds, - }; - } - - function findVisibleCellCoordByIds( - rowId: string | null, - columnId: string | null, - ): { row: number; col: number } | null { - if (!rowId || !columnId) { - return null; - } - const row = visibleRows.findIndex((entry) => entry.id === rowId); - const col = columns.findIndex((entry) => entry.id === columnId); - if (row < 0 || col < 0) { - return null; - } - return { row, col }; - } - - function findVisibleCellCoordByStorage( - row: number, - col: number, - ): { row: number; col: number } | null { - const rowIndex = visibleRows.findIndex( - (entry) => entry.crdtRowIndex === row, - ); - const colIndex = columns.findIndex( - (entry) => entry.columnIndex === col, - ); - if (rowIndex < 0 || colIndex < 0) { - return null; - } - return { row: rowIndex, col: colIndex }; - } - - function normalizeDatabaseCellSelection( - selection: CellSelection, - ): CellSelection | null { - if (columns.length === 0) { - return null; - } - if (visibleRows.length === 0) { - return { - type: "cell", - blockId, - anchor: selection.anchor, - head: selection.head, - }; - } - - const firstVisibleCell = { row: 0, col: 0 }; - const anchorCoord = - findVisibleCellCoordByIds( - selection.rowIds?.[selection.anchor.row] ?? null, - selection.columnIds?.[selection.anchor.col] ?? null, - ) ?? - findVisibleCellCoordByStorage( - selection.anchor.row, - selection.anchor.col, - ) ?? - firstVisibleCell; - const headCoord = - findVisibleCellCoordByIds( - selection.rowIds?.[selection.head.row] ?? null, - selection.columnIds?.[selection.head.col] ?? null, - ) ?? - findVisibleCellCoordByStorage( - selection.head.row, - selection.head.col, - ) ?? - anchorCoord; - - return createDatabaseCellSelection(anchorCoord, headCoord); - } - - function areSelectionAxesEqual( - left: string[] | undefined, - right: string[], - ): boolean { - if (!left || left.length !== right.length) { - return false; - } - return left.every((value, index) => value === right[index]); - } - - function isDatabaseSelectionCurrent(selection: CellSelection): boolean { - if (visibleRows.length === 0) { - return !selection.rowIds && !selection.columnIds; - } - - return ( - areSelectionAxesEqual(selection.rowIds, visibleRowIds) && - areSelectionAxesEqual(selection.columnIds, visibleSelectionColumnIds) - ); - } - - // --- Mutation handlers --- - - function updateViewState(patch: Partial>) { - const nextView = { - ...viewState, - ...patch, - }; - setViewState(nextView); - editor.apply([ - { - type: "database-update-view", - blockId, - viewId: block.databasePrimaryViewId() ?? undefined, - patch, - }, - ], { origin: "user" }); - } - - function handleTitleClick() { - if (isUiReadonly) return; - setIsEditingTitle(true); - } - - function handleTitleBlur(event: React.FocusEvent) { - setIsEditingTitle(false); - const nextTitle = event.currentTarget.value.trim() || "Untitled"; - if (nextTitle === title) return; - editor.apply([ - { - type: "update-block", - blockId, - props: { title: nextTitle }, - }, - ]); - } - - function handleTitleKeyDown(event: React.KeyboardEvent) { - if (event.key === "Enter" || event.key === "Escape") { - event.currentTarget.blur(); - } - } - - function handleCellMouseDown( - event: React.MouseEvent, - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, - ) { - if (!fieldEditor) return; - const isEditing = - fieldEditorActiveCell?.blockId === blockId - && fieldEditorActiveCell.row === row.crdtRowIndex - && fieldEditorActiveCell.col === column.columnIndex; - if (isEditing) return; - const nextCoord = findVisibleCellCoordByIds(row.id, column.id); - if (!nextCoord) return; - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation?.(); - const isSameSingleCellSelection = - cellSelection && - cellSelection.anchor.row === nextCoord.row && - cellSelection.anchor.col === nextCoord.col && - cellSelection.head.row === nextCoord.row && - cellSelection.head.col === nextCoord.col; - if (!event.shiftKey && isSameSingleCellSelection) { - editor.selectBlock(blockId); - return; - } - if (event.shiftKey && cellSelection) { - editor.setSelection( - createDatabaseCellSelection(cellSelection.anchor, nextCoord), - ); - return; - } - editor.setSelection(createDatabaseCellSelection(nextCoord)); - } - - function handleCellDoubleClick( - event: React.MouseEvent, - row: DatabaseViewModelRow, - column: DatabaseViewModelColumn, - ) { - if (isDataReadonly || !fieldEditor) return; - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation?.(); - const cellSurface = event.currentTarget.querySelector(`[${DATA_ATTRS.fieldEditorSurface}]`) as HTMLElement | null; - if (cellSurface) { - fieldEditor.activateCellFromElement?.(blockId, row.crdtRowIndex, column.columnIndex, cellSurface) - ?? fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); - return; - } - fieldEditor.activateCell?.(blockId, row.crdtRowIndex, column.columnIndex); - } - - function handleHeaderClick(event: React.MouseEvent, columnId: string) { - const nextSort = getNextSortState(viewState.sort ?? [], columnId, event.shiftKey); - updateViewState({ sort: nextSort, pageIndex: 0 }); - } - - function handleAddRow() { - if (isDataReadonly) return; - editor.apply([ - { - type: "database-insert-row", - blockId, - index: block.tableRowCount(), - }, - ], { origin: "user" }); - } - - function handleAddColumn() { - if (isUiReadonly) return; - const columnId = generateId(); - const nextColumn: DatabaseColumnDef = { - id: columnId, - title: "New column", - type: "text", - }; - editor.apply([ - { - type: "database-add-column", - blockId, - column: nextColumn, - index: block.tableColumnCount(), - viewId: block.databasePrimaryViewId() ?? undefined, - }, - ], { origin: "user" }); - } - - function handleAddView(nextType: DatabaseViewState["type"]) { - if (isUiReadonly) return; - const nextViewId = generateId(); - const nextView = createDatabaseViewDefinition({ - id: nextViewId, - type: nextType, - columns: columnSchema, - existingViews: databaseViews, - }); - setViewState(nextView); - editor.apply([ - { - type: "database-add-view", - blockId, - view: nextView, - }, - { - type: "database-set-active-view", - blockId, - viewId: nextViewId, - }, - ], { origin: "user" }); - setShowAddViewMenu(false); - } - - function handleSetActiveView(viewId: string) { - const nextView = databaseViews.find((view) => view.id === viewId); - if (nextView) { - setViewState(nextView); - } - editor.apply([ - { - type: "database-set-active-view", - blockId, - viewId, - }, - ], { origin: "user" }); - } - - function handleRemoveView(viewId: string) { - if (isUiReadonly || databaseViews.length <= 1) return; - const currentActiveViewId = block.databasePrimaryViewId() ?? viewState.id; - if (currentActiveViewId === viewId) { - const fallbackView = databaseViews.find((view) => view.id !== viewId); - if (fallbackView) { - setViewState(fallbackView); - } - } - editor.apply([ - { - type: "database-remove-view", - blockId, - viewId, - }, - ], { origin: "user" }); - } - - function handleToggleAllRows() { - if (allVisibleSelected) { - const nextSelection = { ...rowSelection }; - for (const rowId of visibleRowIds) { - delete nextSelection[rowId]; - } - setRowSelection(nextSelection); - return; - } - const nextSelection = { ...rowSelection }; - for (const rowId of visibleRowIds) { - nextSelection[rowId] = true; - } - setRowSelection(nextSelection); - } - - function handleToggleRow(rowId: string) { - setRowSelection((previous) => ({ - ...previous, - [rowId]: !previous[rowId], - })); - } - - function getSelectedRowIds( - fallback?: { rowId: string; checked: boolean }, - ): string[] { - const selectedRowIds = allRows - .filter((row) => rowSelection[row.id]) - .map((row) => row.id); - if ( - fallback?.checked && - !selectedRowIds.includes(fallback.rowId) - ) { - selectedRowIds.push(fallback.rowId); - } - return selectedRowIds; - } - - function handleRowSelectionKeyDown( - event: React.KeyboardEvent, - rowId: string, - ) { - if (event.key !== "Backspace" && event.key !== "Delete") { - return; - } - event.preventDefault(); - event.stopPropagation(); - handleDeleteSelectedRows({ - rowId, - checked: event.currentTarget.checked, - }); - } - - function handleDeleteSelectedRows( - fallback?: { rowId: string; checked: boolean }, - ) { - const selectedRowIds = getSelectedRowIds(fallback); - if (selectedRowIds.length === 0 || isDataReadonly) return; - editor.apply([ - { - type: "database-delete-rows", - blockId, - rowIds: selectedRowIds, - }, - ], { origin: "user" }); - setRowSelection({}); - } - - function handlePinSelectedRows(target: "top" | "bottom" | "none") { - const selectedRowIds = getSelectedRowIds(); - if (selectedRowIds.length === 0) { - return; - } - const currentRowPinning = viewState.rowPinning; - const nextRowPinning = getNextRowPinningState( - currentRowPinning, - selectedRowIds, - target, - ); - updateViewState({ rowPinning: nextRowPinning, pageIndex: 0 }); - } - - function handleDeleteColumn(columnId: string) { - if (isUiReadonly) return; - editor.apply([ - { type: "database-remove-column", blockId, columnId }, - ], { origin: "user" }); - setActiveColumnMenu(null); - } - - function handleRenameColumn(columnId: string, nextTitle: string) { - editor.apply([{ - type: "database-update-column", - blockId, - columnId, - patch: { title: nextTitle || "Untitled" }, - }], { origin: "user" }); - setActiveColumnMenu(null); - } - - function handleChangeColumnType(columnId: string, nextType: ColumnType) { - const targetColumn = columnSchema.find((column) => column.id === columnId); - if (!targetColumn || targetColumn.type === nextType) return; - editor.apply([{ - type: "database-convert-column", - blockId, - columnId, - toType: nextType, - }], { origin: "user" }); - setActiveColumnMenu(null); - } - - function handleToggleColumnVisibility(columnId: string) { - const nextVisibleColumnIds = visibleColumnIdSet.has(columnId) - ? visibleColumnIds.filter((id) => id !== columnId) - : [...visibleColumnIds, columnId]; - updateViewState({ visibleColumnIds: nextVisibleColumnIds }); - } - - function handleChangeColumnPin( - columnId: string, - nextPinned: "left" | "right" | undefined, - ) { - editor.apply([{ - type: "database-update-column", - blockId, - columnId, - patch: { pinned: nextPinned }, - }], { origin: "user" }); - setActiveColumnMenu(null); - } - - function refreshColumnSchemaSoon() { - requestAnimationFrame(() => { - setColumnSchemaRefreshToken((value) => value + 1); - }); - } - - function handleAddOption(columnId: string, value: string, color?: string) { - const trimmedValue = value.trim(); - if (!trimmedValue) return; - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "add", - option: { - id: generateId(), - value: trimmedValue, - color, - }, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleRenameOption(columnId: string, optionId: string, value: string) { - const trimmedValue = value.trim(); - if (!trimmedValue) return; - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "rename", - optionId, - value: trimmedValue, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleRecolorOption(columnId: string, optionId: string, color: string) { - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "recolor", - optionId, - color, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleRemoveOption(columnId: string, optionId: string) { - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "remove", - optionId, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleMoveOption(columnId: string, optionId: string, direction: "up" | "down") { - const column = columnSchema.find((entry) => entry.id === columnId); - const currentOptions = column?.options ?? []; - const currentIndex = currentOptions.findIndex((option) => option.id === optionId); - if (currentIndex < 0) return; - const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; - if (targetIndex < 0 || targetIndex >= currentOptions.length) return; - const nextOrder = [...currentOptions.map((option) => option.id)]; - const [movedOptionId] = nextOrder.splice(currentIndex, 1); - nextOrder.splice(targetIndex, 0, movedOptionId); - editor.apply([{ - type: "database-update-select-options", - blockId, - columnId, - action: "reorder", - order: nextOrder, - }], { origin: "user" }); - refreshColumnSchemaSoon(); - } - - function handleFilterGroupChange(nextFilter: FilterGroup | null) { - updateViewState({ filter: nextFilter, pageIndex: 0 }); - } - - function handleSortChange(nextSort: NonNullable) { - updateViewState({ sort: nextSort, pageIndex: 0 }); - } - - function handleChangeGroupBy(nextGroupBy: string | null) { - updateViewState({ groupBy: nextGroupBy, pageIndex: 0 }); - } - - function handlePreviousPage() { - updateViewState({ pageIndex: Math.max(0, (viewState.pageIndex ?? 0) - 1) }); - } - - function handleNextPage() { - updateViewState({ pageIndex: Math.min(pageCount - 1, (viewState.pageIndex ?? 0) + 1) }); - } - - function setGlobalSearch(value: string) { - setGlobalSearchRaw(value); - updateViewState({ pageIndex: 0 }); - } - - function isCellSelectedFn(row: number, column: number): boolean { - return !!( - cellSelection && - isCellInSelection(cellSelection, row, column, { - rowId: visibleRows.find((entry) => entry.crdtRowIndex === row)?.id, - columnId: columns.find((entry) => entry.columnIndex === column)?.id, - }) - ); - } - - function formatRemoteCell(row: DatabaseViewModelRow, column: DatabaseViewModelColumn): string { - return engine.formatCellDisplay( - row.cells[column.id] ?? "", - column.type, - column.format, - column.options, - ); - } - const activeCalendarMonth = calendarMonth ?? inferCalendarMonth(allRows, calendarDateColumn?.id ?? null); - - function shiftCalendarMonthFn(amount: number) { - setCalendarMonth(shiftMonth(activeCalendarMonth, amount)); - } + const mutationHandlers = createDatabaseMutationHandlers({ + activeCalendarMonth, + allRows, + block, + blockId, + calendarMonth, + cellSelection, + columnSchema, + columns, + databaseViews, + editor, + engine, + globalSearch, + isDataReadonly, + isUiReadonly, + pageCount, + rowSelection, + setActiveColumnMenu, + setCalendarMonth, + setColumnSchemaRefreshToken, + setGlobalSearchRaw, + setIsEditingTitle, + setShowAddViewMenu, + setViewState, + title, + viewState, + visibleColumnIds, + visibleColumnIdSet, + visibleRows, + }); + const { + updateViewState, + handleTitleClick, + handleTitleBlur, + handleTitleKeyDown, + handleHeaderClick, + handleAddRow, + handleAddColumn, + handleAddView, + handleSetActiveView, + handleRemoveView, + handleDeleteColumn, + handleRenameColumn, + handleChangeColumnType, + handleToggleColumnVisibility, + handleChangeColumnPin, + handleAddOption, + handleRenameOption, + handleRecolorOption, + handleRemoveOption, + handleMoveOption, + handleFilterGroupChange, + handleSortChange, + handleChangeGroupBy, + handlePreviousPage, + handleNextPage, + setGlobalSearch, + isCellSelectedFn, + formatRemoteCell, + shiftCalendarMonthFn, + } = mutationHandlers; + const selectionHandlers = createDatabaseSelectionHandlers({ + allRows, + allVisibleSelected, + blockId, + cellSelection, + columns, + editor, + fieldEditor, + fieldEditorActiveCell, + isDataReadonly, + rowSelection, + setRowSelection, + updateViewState, + viewState, + visibleRowIds, + visibleRows, + visibleSelectionColumnIds, + }); + const { + createDatabaseCellSelection, + normalizeDatabaseCellSelection, + isDatabaseSelectionCurrent, + handleCellMouseDown, + handleCellDoubleClick, + handleToggleAllRows, + handleToggleRow, + getSelectedRowIds, + handleRowSelectionKeyDown, + handleDeleteSelectedRows, + handlePinSelectedRows, + } = selectionHandlers; // --- Effects --- diff --git a/packages/extensions/database/src/utils/databaseRenderer.ts b/packages/extensions/database/src/utils/databaseRenderer.ts index ece65a6..daa202c 100644 --- a/packages/extensions/database/src/utils/databaseRenderer.ts +++ b/packages/extensions/database/src/utils/databaseRenderer.ts @@ -10,20 +10,26 @@ import type { FilterOperator, } from "../types"; +export { + DATE_RELATIVE_FILTER_OPTIONS, + addFilterNodeAtPath, + createDefaultFilterCondition, + dateFilterNeedsValue, + defaultOperatorFor, + getDateFilterRangeValue, + getDateFilterSingleValue, + getDefaultFilterValue, + getDefaultFilterValueForOperator, + getFilterPathKey, + operatorNeedsValue, + operatorOptionsFor, + removeFilterNodeAtPath, + updateFilterConditionAtPath, + updateFilterGroupOperatorAtPath, +} from "./databaseRendererFilters"; const PINNED_CELL_Z_INDEX = 2; const PINNED_HEADER_Z_INDEX = 3; -const DATE_RELATIVE_FILTER_OPTIONS = [ - { value: "today", label: "Today" }, - { value: "yesterday", label: "Yesterday" }, - { value: "tomorrow", label: "Tomorrow" }, - { value: "this_week", label: "This week" }, - { value: "last_7_days", label: "Last 7 days" }, - { value: "next_7_days", label: "Next 7 days" }, - { value: "this_month", label: "This month" }, -] as const; -type FilterNode = FilterCondition | FilterGroup; -type FilterPath = number[]; export const CALENDAR_WEEKDAY_LABELS = [ "Sun", @@ -308,236 +314,6 @@ export function getFixedEdgeStyle( } as FixedEdgeStyle; } -export function createDefaultFilterCondition( - columnSchema: DatabaseColumnDef[], -): FilterCondition { - const firstColumn = columnSchema[0]; - if (!firstColumn) { - return { - columnId: "", - operator: "contains", - value: "", - }; - } - return { - columnId: firstColumn.id, - operator: defaultOperatorFor(firstColumn.type), - value: getDefaultFilterValue(firstColumn.type), - }; -} - -export function getDefaultFilterValue( - columnType: ColumnType, -): FilterCondition["value"] { - return getDefaultFilterValueForOperator( - columnType, - defaultOperatorFor(columnType), - ); -} - -export function getDefaultFilterValueForOperator( - columnType: ColumnType, - operator: FilterOperator, -): FilterCondition["value"] { - if (!operatorNeedsValue(operator)) { - return null; - } - if (columnType === "date") { - if (operator === "is_between") { - return ["", ""]; - } - if (operator === "is_relative") { - return DATE_RELATIVE_FILTER_OPTIONS[0]?.value ?? "today"; - } - } - return ""; -} - -export function operatorNeedsValue(operator: FilterOperator): boolean { - return ![ - "is_empty", - "is_not_empty", - "is_checked", - "is_unchecked", - ].includes(operator); -} - -export function dateFilterNeedsValue(operator: FilterOperator): boolean { - return operatorNeedsValue(operator); -} - -export function getDateFilterSingleValue( - value: FilterCondition["value"], -): string { - return typeof value === "string" ? value : ""; -} - -export function getDateFilterRangeValue( - value: FilterCondition["value"], - index: 0 | 1, -): string { - if (!Array.isArray(value)) { - return ""; - } - return value[index] ?? ""; -} - -export function defaultOperatorFor(columnType: ColumnType): FilterOperator { - if (columnType === "checkbox") { - return "is_checked"; - } - if (columnType === "number") { - return "="; - } - if (columnType === "date") { - return "is"; - } - if (columnType === "select") { - return "is"; - } - if (columnType === "multiSelect") { - return "is_any_of"; - } - return "contains"; -} - -export function operatorOptionsFor( - columnType: ColumnType, -): Array<{ value: FilterOperator; label: string }> { - if (columnType === "checkbox") { - return [ - { value: "is_checked", label: "is checked" }, - { value: "is_unchecked", label: "is unchecked" }, - ]; - } - if (columnType === "number") { - return [ - { value: "=", label: "=" }, - { value: "!=", label: "!=" }, - { value: ">", label: ">" }, - { value: "<", label: "<" }, - { value: ">=", label: ">=" }, - { value: "<=", label: "<=" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - if (columnType === "date") { - return [ - { value: "is", label: "is" }, - { value: "is_before", label: "is before" }, - { value: "is_after", label: "is after" }, - { value: "is_between", label: "is between" }, - { value: "is_relative", label: "is relative to today" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - if (columnType === "select") { - return [ - { value: "is", label: "is" }, - { value: "is_not", label: "is not" }, - { value: "is_any_of", label: "is any of" }, - { value: "is_none_of", label: "is none of" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - if (columnType === "multiSelect") { - return [ - { value: "contains", label: "contains" }, - { value: "not_contains", label: "does not contain" }, - { value: "is_any_of", label: "is any of" }, - { value: "is_none_of", label: "is none of" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; - } - return [ - { value: "contains", label: "contains" }, - { value: "not_contains", label: "does not contain" }, - { value: "is", label: "is" }, - { value: "is_not", label: "is not" }, - { value: "starts_with", label: "starts with" }, - { value: "ends_with", label: "ends with" }, - { value: "is_empty", label: "is empty" }, - { value: "is_not_empty", label: "is not empty" }, - ]; -} - -export function getFilterPathKey(path: number[]): string { - return path.length > 0 ? path.join("-") : "root"; -} - -export function updateFilterGroupOperatorAtPath( - root: FilterGroup, - path: number[], - operator: FilterGroup["operator"], -): FilterGroup { - return updateFilterGroupAtPath(root, path, (group) => ({ - ...group, - operator, - })); -} - -export function updateFilterConditionAtPath( - root: FilterGroup, - path: number[], - patch: Partial, -): FilterGroup { - if (path.length === 0) { - return root; - } - const [index, ...rest] = path; - const nextConditions = root.conditions.map((condition, conditionIndex) => { - if (conditionIndex !== index) { - return condition; - } - if (rest.length > 0 && isFilterGroupNode(condition)) { - return updateFilterConditionAtPath(condition, rest, patch); - } - if (isFilterGroupNode(condition)) { - return condition; - } - return { - ...condition, - ...patch, - }; - }); - return { - ...root, - conditions: nextConditions, - }; -} - -export function addFilterNodeAtPath( - root: FilterGroup, - path: number[], - node: FilterNode, -): FilterGroup { - return updateFilterGroupAtPath(root, path, (group) => ({ - ...group, - conditions: [...group.conditions, node], - })); -} - -export function removeFilterNodeAtPath( - root: FilterGroup, - path: number[], -): FilterGroup { - if (path.length === 0) { - return { ...root, conditions: [] }; - } - const parentPath = path.slice(0, -1); - const targetIndex = path[path.length - 1] ?? -1; - return updateFilterGroupAtPath(root, parentPath, (group) => ({ - ...group, - conditions: group.conditions.filter( - (_, conditionIndex) => conditionIndex !== targetIndex, - ), - })); -} - export function getDefaultViewTitle(viewType: DatabaseViewState["type"]): string { switch (viewType) { case "list": @@ -601,29 +377,3 @@ function getNextViewTitle( : `${baseTitle} ${matchingViews.length + 1}`; } -function isFilterGroupNode(value: FilterNode): value is FilterGroup { - return "conditions" in value; -} - -function updateFilterGroupAtPath( - root: FilterGroup, - path: FilterPath, - updater: (group: FilterGroup) => FilterGroup, -): FilterGroup { - if (path.length === 0) { - return updater(root); - } - const [index, ...rest] = path; - const nextConditions = root.conditions.map((condition, conditionIndex) => { - if (conditionIndex !== index || !isFilterGroupNode(condition)) { - return condition; - } - return updateFilterGroupAtPath(condition, rest, updater); - }); - return { - ...root, - conditions: nextConditions, - }; -} - -export { DATE_RELATIVE_FILTER_OPTIONS }; diff --git a/packages/extensions/database/src/utils/databaseRendererFilters.ts b/packages/extensions/database/src/utils/databaseRendererFilters.ts new file mode 100644 index 0000000..a0252d1 --- /dev/null +++ b/packages/extensions/database/src/utils/databaseRendererFilters.ts @@ -0,0 +1,275 @@ +import type { + ColumnType, + DatabaseColumnDef, + DatabaseViewState, + FilterCondition, + FilterGroup, + FilterOperator, +} from "../types"; + +export const DATE_RELATIVE_FILTER_OPTIONS = [ + { value: "today", label: "Today" }, + { value: "yesterday", label: "Yesterday" }, + { value: "tomorrow", label: "Tomorrow" }, + { value: "this_week", label: "This week" }, + { value: "last_7_days", label: "Last 7 days" }, + { value: "next_7_days", label: "Next 7 days" }, + { value: "this_month", label: "This month" }, +] as const; +type FilterNode = FilterCondition | FilterGroup; +type FilterPath = number[]; +export function createDefaultFilterCondition( + columnSchema: DatabaseColumnDef[], +): FilterCondition { + const firstColumn = columnSchema[0]; + if (!firstColumn) { + return { + columnId: "", + operator: "contains", + value: "", + }; + } + return { + columnId: firstColumn.id, + operator: defaultOperatorFor(firstColumn.type), + value: getDefaultFilterValue(firstColumn.type), + }; +} + +export function getDefaultFilterValue( + columnType: ColumnType, +): FilterCondition["value"] { + return getDefaultFilterValueForOperator( + columnType, + defaultOperatorFor(columnType), + ); +} + +export function getDefaultFilterValueForOperator( + columnType: ColumnType, + operator: FilterOperator, +): FilterCondition["value"] { + if (!operatorNeedsValue(operator)) { + return null; + } + if (columnType === "date") { + if (operator === "is_between") { + return ["", ""]; + } + if (operator === "is_relative") { + return DATE_RELATIVE_FILTER_OPTIONS[0]?.value ?? "today"; + } + } + return ""; +} + +export function operatorNeedsValue(operator: FilterOperator): boolean { + return ![ + "is_empty", + "is_not_empty", + "is_checked", + "is_unchecked", + ].includes(operator); +} + +export function dateFilterNeedsValue(operator: FilterOperator): boolean { + return operatorNeedsValue(operator); +} + +export function getDateFilterSingleValue( + value: FilterCondition["value"], +): string { + return typeof value === "string" ? value : ""; +} + +export function getDateFilterRangeValue( + value: FilterCondition["value"], + index: 0 | 1, +): string { + if (!Array.isArray(value)) { + return ""; + } + return value[index] ?? ""; +} + +export function defaultOperatorFor(columnType: ColumnType): FilterOperator { + if (columnType === "checkbox") { + return "is_checked"; + } + if (columnType === "number") { + return "="; + } + if (columnType === "date") { + return "is"; + } + if (columnType === "select") { + return "is"; + } + if (columnType === "multiSelect") { + return "is_any_of"; + } + return "contains"; +} + +export function operatorOptionsFor( + columnType: ColumnType, +): Array<{ value: FilterOperator; label: string }> { + if (columnType === "checkbox") { + return [ + { value: "is_checked", label: "is checked" }, + { value: "is_unchecked", label: "is unchecked" }, + ]; + } + if (columnType === "number") { + return [ + { value: "=", label: "=" }, + { value: "!=", label: "!=" }, + { value: ">", label: ">" }, + { value: "<", label: "<" }, + { value: ">=", label: ">=" }, + { value: "<=", label: "<=" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + if (columnType === "date") { + return [ + { value: "is", label: "is" }, + { value: "is_before", label: "is before" }, + { value: "is_after", label: "is after" }, + { value: "is_between", label: "is between" }, + { value: "is_relative", label: "is relative to today" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + if (columnType === "select") { + return [ + { value: "is", label: "is" }, + { value: "is_not", label: "is not" }, + { value: "is_any_of", label: "is any of" }, + { value: "is_none_of", label: "is none of" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + if (columnType === "multiSelect") { + return [ + { value: "contains", label: "contains" }, + { value: "not_contains", label: "does not contain" }, + { value: "is_any_of", label: "is any of" }, + { value: "is_none_of", label: "is none of" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; + } + return [ + { value: "contains", label: "contains" }, + { value: "not_contains", label: "does not contain" }, + { value: "is", label: "is" }, + { value: "is_not", label: "is not" }, + { value: "starts_with", label: "starts with" }, + { value: "ends_with", label: "ends with" }, + { value: "is_empty", label: "is empty" }, + { value: "is_not_empty", label: "is not empty" }, + ]; +} + +export function getFilterPathKey(path: number[]): string { + return path.length > 0 ? path.join("-") : "root"; +} + +export function updateFilterGroupOperatorAtPath( + root: FilterGroup, + path: number[], + operator: FilterGroup["operator"], +): FilterGroup { + return updateFilterGroupAtPath(root, path, (group) => ({ + ...group, + operator, + })); +} + +export function updateFilterConditionAtPath( + root: FilterGroup, + path: number[], + patch: Partial, +): FilterGroup { + if (path.length === 0) { + return root; + } + const [index, ...rest] = path; + const nextConditions = root.conditions.map((condition, conditionIndex) => { + if (conditionIndex !== index) { + return condition; + } + if (rest.length > 0 && isFilterGroupNode(condition)) { + return updateFilterConditionAtPath(condition, rest, patch); + } + if (isFilterGroupNode(condition)) { + return condition; + } + return { + ...condition, + ...patch, + }; + }); + return { + ...root, + conditions: nextConditions, + }; +} + +export function addFilterNodeAtPath( + root: FilterGroup, + path: number[], + node: FilterNode, +): FilterGroup { + return updateFilterGroupAtPath(root, path, (group) => ({ + ...group, + conditions: [...group.conditions, node], + })); +} + +export function removeFilterNodeAtPath( + root: FilterGroup, + path: number[], +): FilterGroup { + if (path.length === 0) { + return { ...root, conditions: [] }; + } + const parentPath = path.slice(0, -1); + const targetIndex = path[path.length - 1] ?? -1; + return updateFilterGroupAtPath(root, parentPath, (group) => ({ + ...group, + conditions: group.conditions.filter( + (_, conditionIndex) => conditionIndex !== targetIndex, + ), + })); +} + +function isFilterGroupNode(value: FilterNode): value is FilterGroup { + return "conditions" in value; +} + +function updateFilterGroupAtPath( + root: FilterGroup, + path: FilterPath, + updater: (group: FilterGroup) => FilterGroup, +): FilterGroup { + if (path.length === 0) { + return updater(root); + } + const [index, ...rest] = path; + const nextConditions = root.conditions.map((condition, conditionIndex) => { + if (conditionIndex !== index || !isFilterGroupNode(condition)) { + return condition; + } + return updateFilterGroupAtPath(condition, rest, updater); + }); + return { + ...root, + conditions: nextConditions, + }; +} + diff --git a/packages/extensions/document-ops/src/__tests__/tools.part2.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part2.test.ts new file mode 100644 index 0000000..54dfe39 --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part2.test.ts @@ -0,0 +1,430 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("guards ToolContext block insertion with the same policy", () => { + const editor = createFakeEditor("flow"); + const emit = vi.fn(); + const context = new ToolContextImpl(editor, "doc-1", emit); + + expect(() => + context.insertBlock("database", {}, "last"), + ).toThrow('Block type "database" is not available in flow documents.'); + + expect(emit).not.toHaveBeenCalled(); + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("allows ToolContext streaming without an undo manager", () => { + const streaming = { + beginStreaming: vi.fn(), + appendDelta: vi.fn(), + endStreaming: vi.fn(), + }; + const editor = { + ...createFakeEditor("structured"), + internals: { + emit: vi.fn(), + getSlot: vi.fn((key: string) => + key === "delta-stream:target" ? streaming : undefined + ), + }, + } as unknown as Editor; + const emit = vi.fn(); + const context = new ToolContextImpl(editor, "doc-1", emit); + + expect(() => { + context.beginStreaming("zone-1", "block-1"); + context.appendDelta("Hello"); + context.endStreaming("complete"); + }).not.toThrow(); + + expect(emit).toHaveBeenCalledWith({ + type: "gen-start", + zoneId: "zone-1", + blockId: "block-1", + }); + expect(emit).toHaveBeenCalledWith({ + type: "gen-delta", + zoneId: "zone-1", + delta: "Hello", + }); + expect(emit).toHaveBeenCalledWith({ + type: "gen-end", + zoneId: "zone-1", + status: "complete", + }); + expect(streaming.beginStreaming).toHaveBeenCalledWith("zone-1", "block-1"); + expect(streaming.appendDelta).toHaveBeenCalledWith("Hello"); + expect(streaming.endStreaming).toHaveBeenCalledWith("complete"); + }); + + it("defaults read_document to a compact summary", async () => { + const editor = createReadDocumentEditor(); + + const result = await readDocumentTool(editor).handler({}, {} as never) as { + blockCount: number; + preview: Array<{ id: string; type: string; content: string }>; + }; + + expect(result.blockCount).toBe(3); + expect(result.preview).toEqual([ + { id: "block-1", type: "paragraph", content: "First accepted" }, + { id: "block-2", type: "paragraph", content: "Second" }, + { id: "block-3", type: "heading", content: "Third" }, + ]); + }); + + it("limits read_document to the requested block range", async () => { + const editor = createReadDocumentEditor(); + + const result = await readDocumentTool(editor).handler( + { + format: "markdown", + range: { + startBlockId: "block-2", + endBlockId: "block-3", + }, + }, + {} as never, + ) as string; + + expect(result).toBe("Second\n\n# Third"); + }); + + it("returns summary context with selection details", async () => { + const editor = createReadDocumentEditor(); + + const result = await getContextTool(editor).handler( + { + format: "summary", + includeSelection: true, + }, + {} as never, + ) as { + blockCount: number; + activeBlockId: string; + selectedText: string; + blocks: Array<{ id: string; preview: string }>; + }; + + expect(result.blockCount).toBe(3); + expect(result.activeBlockId).toBe("block-2"); + expect(result.selectedText).toBe("Second"); + expect(result.blocks.map((block) => block.id)).toEqual([ + "block-1", + "block-2", + "block-3", + ]); + }); + + it("returns cursor context without reading the full document", async () => { + const editor = createReadDocumentEditor(); + + const result = await getCursorContextTool(editor).handler({}, {} as never) as { + activeBlockId: string | null; + activeBlockType: string | null; + selectedText: string | null; + surroundingBlocks: Array<{ id: string }>; + structuredTarget: { target: { kind: string }; validOperations: string[] } | null; + }; + + expect(result.activeBlockId).toBe("block-2"); + expect(result.activeBlockType).toBe("paragraph"); + expect(result.selectedText).toBe("Second"); + expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ + "block-1", + "block-2", + "block-3", + ]); + expect(result.structuredTarget?.target.kind).toBe("block"); + expect(result.structuredTarget?.validOperations).toContain("replace_text"); + }); + +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.part3.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part3.test.ts new file mode 100644 index 0000000..5138dfa --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part3.test.ts @@ -0,0 +1,434 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("uses bounded neighbor traversal for cursor context when block links exist", async () => { + const blocks: Array<{ + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: () => string; + textDeltas: () => Array<{ insert: string }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; + }> = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "First", + textDeltas: () => [{ insert: "First" }], + prev: null, + next: null, + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Second", + textDeltas: () => [{ insert: "Second" }], + prev: null, + next: null, + }), + createMockBlockHandle({ + id: "block-3", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Third", + textDeltas: () => [{ insert: "Third" }], + prev: null, + next: null, + }), + ]; + blocks[0].next = blocks[1]; + blocks[1].prev = blocks[0]; + blocks[1].next = blocks[2]; + blocks[2].prev = blocks[1]; + + const editor = { + documentProfile: "structured", + schema: defaultSchema, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + blocks: vi.fn(() => { + throw new Error("Cursor context should not scan the full document."); + }), + } as unknown as Editor; + + const result = await getCursorContextTool(editor).handler({}, {} as never) as { + surroundingBlocks: Array<{ id: string }>; + }; + + expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ + "block-1", + "block-2", + "block-3", + ]); + }); + + it("inspects table targets with schema-aware details", async () => { + const editor = createStructuredTargetEditor("table-1"); + + const result = await inspectTargetTool(editor).handler({}, {} as never) as { + target: { + target: { + kind: string; + rowCount: number; + columnCount: number; + }; + validOperations: string[]; + } | null; + }; + + expect(result.target?.target).toMatchObject({ + kind: "table", + rowCount: 3, + columnCount: 2, + }); + expect(result.target?.validOperations).toContain("insert_row"); + expect(result.target?.validOperations).toContain("set_cell_text"); + }); + + it("inspects database targets with view metadata", async () => { + const editor = createStructuredTargetEditor("database-1"); + + const result = await inspectTargetTool(editor).handler({}, {} as never) as { + target: { + target: { + kind: string; + rowCount: number; + activeViewId: string | null; + }; + validOperations: string[]; + } | null; + }; + + expect(result.target?.target).toMatchObject({ + kind: "database", + rowCount: 2, + activeViewId: "view-1", + }); + expect(result.target?.validOperations).toContain("add_column"); + expect(result.target?.validOperations).toContain("set_active_view"); + }); + + it("returns no valid mutation operations for read-only targets", async () => { + const editor = createStructuredTargetEditor("subdocument-1"); + + const result = await listValidOperationsTool(editor).handler({}, {} as never) as { + operations: string[]; + }; + + expect(result.operations).toEqual([]); + }); + +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.part4.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part4.test.ts new file mode 100644 index 0000000..d7ed489 --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part4.test.ts @@ -0,0 +1,427 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("rejects block mutations against read-only targets", async () => { + const editor = createStructuredTargetEditor("subdocument-1"); + + await expect( + updateBlockTool(editor).handler( + { + blockId: "subdocument-1", + props: { title: "Forbidden" }, + }, + {} as never, + ), + ).rejects.toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + await expect( + deleteBlockTool(editor).handler( + { blockId: "subdocument-1" }, + {} as never, + ), + ).rejects.toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + await expect( + moveBlockTool(editor).handler( + { + blockId: "subdocument-1", + position: "last", + }, + {} as never, + ), + ).rejects.toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("guards ToolContext block mutations with the same policy", () => { + const editor = createStructuredTargetEditor("subdocument-1"); + const emit = vi.fn(); + const context = new ToolContextImpl(editor, "doc-1", emit); + + expect(() => + context.updateBlock("subdocument-1", { title: "Forbidden" }), + ).toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + expect(() => context.deleteBlock("subdocument-1")).toThrow( + 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', + ); + + expect(emit).not.toHaveBeenCalled(); + expect(editor.apply).not.toHaveBeenCalled(); + }); + + it("returns raw text when suggestions are included", async () => { + const editor = createReadDocumentEditor(); + + const result = await readDocumentTool(editor).handler( + { + format: "json", + includeSuggestions: true, + }, + {} as never, + ) as { + viewMode: string; + blocks: Array<{ id: string; content: string }>; + }; + + expect(result.viewMode).toBe("raw"); + expect(result.blocks.find((block) => block.id === "block-2")?.content).toBe( + "Second draft", + ); + }); + + it("includes nested blocks when reading document ranges", async () => { + const editor = createNestedDocumentEditor(); + + const result = await readDocumentTool(editor).handler( + { format: "summary" }, + {} as never, + ) as { + preview: Array<{ id: string }>; + }; + + expect(result.preview.map((block) => block.id)).toEqual([ + "heading-1", + "layout-1", + "paragraph-1", + ]); + }); + + it("searches nested blocks through the shared document traversal", async () => { + const editor = createNestedDocumentEditor(); + + const result = await searchDocumentTool(editor).handler( + { query: "stable block identity" }, + {} as never, + ) as Array<{ blockId: string }>; + + expect(result).toEqual([ + expect.objectContaining({ blockId: "paragraph-1" }), + ]); + }); + + it("retrieves ranked spans with nested-block and heading metadata", async () => { + const editor = createNestedDocumentEditor(); + + const result = await retrieveDocumentSpansTool(editor).handler( + { + query: "stable block identity architecture", + activeBlockId: "paragraph-1", + targetBlockId: "paragraph-1", + }, + {} as never, + ) as { + spans: Array<{ + id: string; + blockIds: string[]; + headingPath: string[]; + score: number; + }>; + }; + + expect(result.spans[0]).toMatchObject({ + id: "span:paragraph-1", + blockIds: ["heading-1", "layout-1", "paragraph-1"], + range: { + startBlockId: "heading-1", + endBlockId: "paragraph-1", + }, + headingPath: ["Architecture"], + }); + expect(result.spans[0]?.score).toBeGreaterThan(0); + }); + +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.part5.test.ts b/packages/extensions/document-ops/src/__tests__/tools.part5.test.ts new file mode 100644 index 0000000..5b107ef --- /dev/null +++ b/packages/extensions/document-ops/src/__tests__/tools.part5.test.ts @@ -0,0 +1,346 @@ +import { defaultSchema } from "@pen/schema-default"; +import type { ApplyOptions, DocumentOp, Editor } from "@pen/types"; +import { describe, expect, it, vi } from "vitest"; +import { ToolContextImpl } from "../toolContext"; +import { ToolRuntimeImpl } from "../toolServer"; +import { getContextTool } from "../tools/getContext"; +import { getCursorContextTool } from "../tools/getCursorContext"; +import { inspectTargetTool } from "../tools/inspectTarget"; +import { insertBlockTool } from "../tools/insertBlock"; +import { listBlockTypesTool } from "../tools/listBlockTypes"; +import { listValidOperationsTool } from "../tools/listValidOperations"; +import { readDocumentTool } from "../tools/readDocument"; +import { searchDocumentTool } from "../tools/searchDocument"; +import { retrieveDocumentSpansTool } from "../tools/retrieveDocumentSpans"; +import { deleteBlockTool } from "../tools/deleteBlock"; +import { moveBlockTool } from "../tools/moveBlock"; +import { updateBlockTool } from "../tools/updateBlock"; +import { writeDocumentTool } from "../tools/writeDocument"; + +function createFakeEditor(documentProfile: Editor["documentProfile"]): Editor { + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + internals: { + emit: vi.fn(), + }, + } as unknown as Editor; +} + +function createDatabaseMarkdown(): string { + return [ + "", + "", + "| Name |", + "| --- |", + "| Ship importer |", + ].join("\n"); +} + +function createMockBlockHandle(input: { + id: string; + type: string; + props?: Record; + children?: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + prev?: unknown; + next?: unknown; +}): { + id: string; + type: string; + props: Record; + children: unknown[]; + textContent: (options?: { resolved?: boolean }) => string; + textDeltas: () => Array<{ insert: string; attributes?: Record }>; + tableRowCount: () => number; + tableColumnCount: () => number; + tableCell: () => null; + tableRow: () => null; + tableColumns: () => never[]; + databaseViews: () => never[]; + databasePrimaryViewId: () => null; + databaseActiveView: () => null; + prev?: unknown; + next?: unknown; +} { + return { + props: {}, + children: [], + prev: null, + next: null, + ...input, + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableCell: () => null, + tableRow: () => null, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }; +} + +function createReadDocumentEditor(): Editor { + const blocks = [ + createMockBlockHandle({ + id: "block-1", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "First accepted" : "First accepted", + textDeltas: () => [{ insert: "First accepted" }], + }), + createMockBlockHandle({ + id: "block-2", + type: "paragraph", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Second" : "Second draft", + textDeltas: () => [ + { insert: "Second" }, + { insert: " draft", attributes: { suggestion: { action: "delete" } } }, + ], + }), + createMockBlockHandle({ + id: "block-3", + type: "heading", + props: {}, + children: [], + textContent: (options?: { resolved?: boolean }) => + options?.resolved ? "Third" : "Third", + textDeltas: () => [{ insert: "Third" }], + }), + ] as const; + for (const block of blocks) { + delete (block as { prev?: unknown }).prev; + delete (block as { next?: unknown }).next; + } + + return { + documentProfile: "structured", + schema: defaultSchema, + blockCount: () => 3, + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "block-2", offset: 0 }, + focus: { blockId: "block-2", offset: 6 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "block-2", offset: 0 }, + end: { blockId: "block-2", offset: 6 }, + blockRange: ["block-2"], + }), + }), + getSelectedText: () => "Second", + } as unknown as Editor; +} + +function createStructuredTargetEditor( + activeBlockId: string, + documentProfile: Editor["documentProfile"] = "structured", +): Editor { + const views = [ + { + id: "view-1", + type: "table" as const, + title: "Default view", + }, + ]; + const blocks = [ + { + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Paragraph", + textDeltas: () => [{ insert: "Paragraph" }], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "table-1", + type: "table", + props: { hasHeaderRow: true }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 3, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "col-1", title: "Name", type: "text" as const }, + { id: "col-2", title: "Status", type: "text" as const }, + ], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + { + id: "database-1", + type: "database", + props: { title: "Roadmap" }, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 2, + tableColumnCount: () => 2, + tableColumns: () => [ + { id: "name", title: "Name", type: "text" as const }, + { id: "owner", title: "Owner", type: "text" as const }, + ], + databaseViews: () => views, + databasePrimaryViewId: () => "view-1", + databaseActiveView: () => views[0], + }, + { + id: "subdocument-1", + type: "subdocument", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + tableRowCount: () => 0, + tableColumnCount: () => 0, + tableColumns: () => [], + databaseViews: () => [], + databasePrimaryViewId: () => null, + databaseActiveView: () => null, + }, + ]; + + return { + documentProfile, + schema: defaultSchema, + apply: vi.fn<(ops: DocumentOp[], options?: ApplyOptions) => void>(), + blocks: () => blocks, + getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "block", + blockIds: [activeBlockId], + }), + getSelectedText: () => "", + } as unknown as Editor; +} + +function createNestedDocumentEditor(): Editor { + const topLevelBlocks = [ + createMockBlockHandle({ + id: "heading-1", + type: "heading", + props: { level: 1 }, + children: [], + textContent: () => "Architecture", + textDeltas: () => [{ insert: "Architecture" }], + }), + createMockBlockHandle({ + id: "layout-1", + type: "columns", + props: {}, + children: [], + textContent: () => "", + textDeltas: () => [], + }), + ]; + const nestedBlocks = [ + topLevelBlocks[0], + topLevelBlocks[1], + createMockBlockHandle({ + id: "paragraph-1", + type: "paragraph", + props: {}, + children: [], + textContent: () => "Fast apply preserves stable block identity.", + textDeltas: () => [{ insert: "Fast apply preserves stable block identity." }], + }), + ]; + + return { + documentProfile: "structured", + schema: defaultSchema, + blocks: () => topLevelBlocks, + documentState: { + allBlocks: () => nestedBlocks, + }, + getBlock: (blockId: string) => + nestedBlocks.find((block) => block.id === blockId) ?? null, + getSelection: () => ({ + type: "text", + anchor: { blockId: "paragraph-1", offset: 0 }, + focus: { blockId: "paragraph-1", offset: 4 }, + isCollapsed: false, + toRange: () => ({ + start: { blockId: "paragraph-1", offset: 0 }, + end: { blockId: "paragraph-1", offset: 4 }, + blockRange: ["paragraph-1"], + }), + }), + getSelectedText: () => "Fast", + } as unknown as Editor; +} + +describe("@pen/document-ops tools", () => { + it("rejects invalid tool inputs at the document-ops runtime boundary", async () => { + const runtime = new ToolRuntimeImpl(); + const searchEditor = createReadDocumentEditor(); + const mutationEditor = createStructuredTargetEditor("paragraph-1"); + runtime.registerTool(searchDocumentTool(searchEditor)); + runtime.registerTool(retrieveDocumentSpansTool(searchEditor)); + runtime.registerTool(moveBlockTool(mutationEditor)); + runtime.registerTool(writeDocumentTool(mutationEditor)); + + await expect( + runtime.executeTool( + "search_document", + { + query: "", + maxResults: 0, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "search_document"'); + await expect( + runtime.executeTool( + "retrieve_document_spans", + { + query: "", + maxResults: 99, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "retrieve_document_spans"'); + await expect( + runtime.executeTool( + "move_block", + { + blockId: "paragraph-1", + position: { + after: "", + }, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "move_block"'); + await expect( + runtime.executeTool( + "write_document", + { + content: "Hello", + position: { + parent: "paragraph-1", + index: -1, + }, + }, + {} as never, + ), + ).rejects.toThrow('Invalid input for tool "write_document"'); + }); +}); diff --git a/packages/extensions/document-ops/src/__tests__/tools.test.ts b/packages/extensions/document-ops/src/__tests__/tools.test.ts index 75c87c8..96065e2 100644 --- a/packages/extensions/document-ops/src/__tests__/tools.test.ts +++ b/packages/extensions/document-ops/src/__tests__/tools.test.ts @@ -442,477 +442,4 @@ describe("@pen/document-ops tools", () => { expect(flowEditor.internals.emit).toHaveBeenCalled(); }); - it("guards ToolContext block insertion with the same policy", () => { - const editor = createFakeEditor("flow"); - const emit = vi.fn(); - const context = new ToolContextImpl(editor, "doc-1", emit); - - expect(() => - context.insertBlock("database", {}, "last"), - ).toThrow('Block type "database" is not available in flow documents.'); - - expect(emit).not.toHaveBeenCalled(); - expect(editor.apply).not.toHaveBeenCalled(); - }); - - it("allows ToolContext streaming without an undo manager", () => { - const streaming = { - beginStreaming: vi.fn(), - appendDelta: vi.fn(), - endStreaming: vi.fn(), - }; - const editor = { - ...createFakeEditor("structured"), - internals: { - emit: vi.fn(), - getSlot: vi.fn((key: string) => - key === "delta-stream:target" ? streaming : undefined - ), - }, - } as unknown as Editor; - const emit = vi.fn(); - const context = new ToolContextImpl(editor, "doc-1", emit); - - expect(() => { - context.beginStreaming("zone-1", "block-1"); - context.appendDelta("Hello"); - context.endStreaming("complete"); - }).not.toThrow(); - - expect(emit).toHaveBeenCalledWith({ - type: "gen-start", - zoneId: "zone-1", - blockId: "block-1", - }); - expect(emit).toHaveBeenCalledWith({ - type: "gen-delta", - zoneId: "zone-1", - delta: "Hello", - }); - expect(emit).toHaveBeenCalledWith({ - type: "gen-end", - zoneId: "zone-1", - status: "complete", - }); - expect(streaming.beginStreaming).toHaveBeenCalledWith("zone-1", "block-1"); - expect(streaming.appendDelta).toHaveBeenCalledWith("Hello"); - expect(streaming.endStreaming).toHaveBeenCalledWith("complete"); - }); - - it("defaults read_document to a compact summary", async () => { - const editor = createReadDocumentEditor(); - - const result = await readDocumentTool(editor).handler({}, {} as never) as { - blockCount: number; - preview: Array<{ id: string; type: string; content: string }>; - }; - - expect(result.blockCount).toBe(3); - expect(result.preview).toEqual([ - { id: "block-1", type: "paragraph", content: "First accepted" }, - { id: "block-2", type: "paragraph", content: "Second" }, - { id: "block-3", type: "heading", content: "Third" }, - ]); - }); - - it("limits read_document to the requested block range", async () => { - const editor = createReadDocumentEditor(); - - const result = await readDocumentTool(editor).handler( - { - format: "markdown", - range: { - startBlockId: "block-2", - endBlockId: "block-3", - }, - }, - {} as never, - ) as string; - - expect(result).toBe("Second\n\n# Third"); - }); - - it("returns summary context with selection details", async () => { - const editor = createReadDocumentEditor(); - - const result = await getContextTool(editor).handler( - { - format: "summary", - includeSelection: true, - }, - {} as never, - ) as { - blockCount: number; - activeBlockId: string; - selectedText: string; - blocks: Array<{ id: string; preview: string }>; - }; - - expect(result.blockCount).toBe(3); - expect(result.activeBlockId).toBe("block-2"); - expect(result.selectedText).toBe("Second"); - expect(result.blocks.map((block) => block.id)).toEqual([ - "block-1", - "block-2", - "block-3", - ]); - }); - - it("returns cursor context without reading the full document", async () => { - const editor = createReadDocumentEditor(); - - const result = await getCursorContextTool(editor).handler({}, {} as never) as { - activeBlockId: string | null; - activeBlockType: string | null; - selectedText: string | null; - surroundingBlocks: Array<{ id: string }>; - structuredTarget: { target: { kind: string }; validOperations: string[] } | null; - }; - - expect(result.activeBlockId).toBe("block-2"); - expect(result.activeBlockType).toBe("paragraph"); - expect(result.selectedText).toBe("Second"); - expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ - "block-1", - "block-2", - "block-3", - ]); - expect(result.structuredTarget?.target.kind).toBe("block"); - expect(result.structuredTarget?.validOperations).toContain("replace_text"); - }); - - it("uses bounded neighbor traversal for cursor context when block links exist", async () => { - const blocks: Array<{ - id: string; - type: string; - props: Record; - children: unknown[]; - textContent: () => string; - textDeltas: () => Array<{ insert: string }>; - tableRowCount: () => number; - tableColumnCount: () => number; - tableCell: () => null; - tableRow: () => null; - tableColumns: () => never[]; - databaseViews: () => never[]; - databasePrimaryViewId: () => null; - databaseActiveView: () => null; - prev?: unknown; - next?: unknown; - }> = [ - createMockBlockHandle({ - id: "block-1", - type: "paragraph", - props: {}, - children: [], - textContent: () => "First", - textDeltas: () => [{ insert: "First" }], - prev: null, - next: null, - }), - createMockBlockHandle({ - id: "block-2", - type: "paragraph", - props: {}, - children: [], - textContent: () => "Second", - textDeltas: () => [{ insert: "Second" }], - prev: null, - next: null, - }), - createMockBlockHandle({ - id: "block-3", - type: "paragraph", - props: {}, - children: [], - textContent: () => "Third", - textDeltas: () => [{ insert: "Third" }], - prev: null, - next: null, - }), - ]; - blocks[0].next = blocks[1]; - blocks[1].prev = blocks[0]; - blocks[1].next = blocks[2]; - blocks[2].prev = blocks[1]; - - const editor = { - documentProfile: "structured", - schema: defaultSchema, - getSelection: () => ({ - type: "text", - anchor: { blockId: "block-2", offset: 0 }, - focus: { blockId: "block-2", offset: 6 }, - isCollapsed: false, - toRange: () => ({ - start: { blockId: "block-2", offset: 0 }, - end: { blockId: "block-2", offset: 6 }, - blockRange: ["block-2"], - }), - }), - getSelectedText: () => "Second", - getBlock: (blockId: string) => blocks.find((block) => block.id === blockId) ?? null, - blocks: vi.fn(() => { - throw new Error("Cursor context should not scan the full document."); - }), - } as unknown as Editor; - - const result = await getCursorContextTool(editor).handler({}, {} as never) as { - surroundingBlocks: Array<{ id: string }>; - }; - - expect(result.surroundingBlocks.map((block) => block.id)).toEqual([ - "block-1", - "block-2", - "block-3", - ]); - }); - - it("inspects table targets with schema-aware details", async () => { - const editor = createStructuredTargetEditor("table-1"); - - const result = await inspectTargetTool(editor).handler({}, {} as never) as { - target: { - target: { - kind: string; - rowCount: number; - columnCount: number; - }; - validOperations: string[]; - } | null; - }; - - expect(result.target?.target).toMatchObject({ - kind: "table", - rowCount: 3, - columnCount: 2, - }); - expect(result.target?.validOperations).toContain("insert_row"); - expect(result.target?.validOperations).toContain("set_cell_text"); - }); - - it("inspects database targets with view metadata", async () => { - const editor = createStructuredTargetEditor("database-1"); - - const result = await inspectTargetTool(editor).handler({}, {} as never) as { - target: { - target: { - kind: string; - rowCount: number; - activeViewId: string | null; - }; - validOperations: string[]; - } | null; - }; - - expect(result.target?.target).toMatchObject({ - kind: "database", - rowCount: 2, - activeViewId: "view-1", - }); - expect(result.target?.validOperations).toContain("add_column"); - expect(result.target?.validOperations).toContain("set_active_view"); - }); - - it("returns no valid mutation operations for read-only targets", async () => { - const editor = createStructuredTargetEditor("subdocument-1"); - - const result = await listValidOperationsTool(editor).handler({}, {} as never) as { - operations: string[]; - }; - - expect(result.operations).toEqual([]); - }); - - it("rejects block mutations against read-only targets", async () => { - const editor = createStructuredTargetEditor("subdocument-1"); - - await expect( - updateBlockTool(editor).handler( - { - blockId: "subdocument-1", - props: { title: "Forbidden" }, - }, - {} as never, - ), - ).rejects.toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - await expect( - deleteBlockTool(editor).handler( - { blockId: "subdocument-1" }, - {} as never, - ), - ).rejects.toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - await expect( - moveBlockTool(editor).handler( - { - blockId: "subdocument-1", - position: "last", - }, - {} as never, - ), - ).rejects.toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - - expect(editor.apply).not.toHaveBeenCalled(); - }); - - it("guards ToolContext block mutations with the same policy", () => { - const editor = createStructuredTargetEditor("subdocument-1"); - const emit = vi.fn(); - const context = new ToolContextImpl(editor, "doc-1", emit); - - expect(() => - context.updateBlock("subdocument-1", { title: "Forbidden" }), - ).toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - expect(() => context.deleteBlock("subdocument-1")).toThrow( - 'Block "subdocument-1" of type "subdocument" is not editable in structured documents.', - ); - - expect(emit).not.toHaveBeenCalled(); - expect(editor.apply).not.toHaveBeenCalled(); - }); - - it("returns raw text when suggestions are included", async () => { - const editor = createReadDocumentEditor(); - - const result = await readDocumentTool(editor).handler( - { - format: "json", - includeSuggestions: true, - }, - {} as never, - ) as { - viewMode: string; - blocks: Array<{ id: string; content: string }>; - }; - - expect(result.viewMode).toBe("raw"); - expect(result.blocks.find((block) => block.id === "block-2")?.content).toBe( - "Second draft", - ); - }); - - it("includes nested blocks when reading document ranges", async () => { - const editor = createNestedDocumentEditor(); - - const result = await readDocumentTool(editor).handler( - { format: "summary" }, - {} as never, - ) as { - preview: Array<{ id: string }>; - }; - - expect(result.preview.map((block) => block.id)).toEqual([ - "heading-1", - "layout-1", - "paragraph-1", - ]); - }); - - it("searches nested blocks through the shared document traversal", async () => { - const editor = createNestedDocumentEditor(); - - const result = await searchDocumentTool(editor).handler( - { query: "stable block identity" }, - {} as never, - ) as Array<{ blockId: string }>; - - expect(result).toEqual([ - expect.objectContaining({ blockId: "paragraph-1" }), - ]); - }); - - it("retrieves ranked spans with nested-block and heading metadata", async () => { - const editor = createNestedDocumentEditor(); - - const result = await retrieveDocumentSpansTool(editor).handler( - { - query: "stable block identity architecture", - activeBlockId: "paragraph-1", - targetBlockId: "paragraph-1", - }, - {} as never, - ) as { - spans: Array<{ - id: string; - blockIds: string[]; - headingPath: string[]; - score: number; - }>; - }; - - expect(result.spans[0]).toMatchObject({ - id: "span:paragraph-1", - blockIds: ["heading-1", "layout-1", "paragraph-1"], - range: { - startBlockId: "heading-1", - endBlockId: "paragraph-1", - }, - headingPath: ["Architecture"], - }); - expect(result.spans[0]?.score).toBeGreaterThan(0); - }); - - it("rejects invalid tool inputs at the document-ops runtime boundary", async () => { - const runtime = new ToolRuntimeImpl(); - const searchEditor = createReadDocumentEditor(); - const mutationEditor = createStructuredTargetEditor("paragraph-1"); - runtime.registerTool(searchDocumentTool(searchEditor)); - runtime.registerTool(retrieveDocumentSpansTool(searchEditor)); - runtime.registerTool(moveBlockTool(mutationEditor)); - runtime.registerTool(writeDocumentTool(mutationEditor)); - - await expect( - runtime.executeTool( - "search_document", - { - query: "", - maxResults: 0, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "search_document"'); - await expect( - runtime.executeTool( - "retrieve_document_spans", - { - query: "", - maxResults: 99, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "retrieve_document_spans"'); - await expect( - runtime.executeTool( - "move_block", - { - blockId: "paragraph-1", - position: { - after: "", - }, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "move_block"'); - await expect( - runtime.executeTool( - "write_document", - { - content: "Hello", - position: { - parent: "paragraph-1", - index: -1, - }, - }, - {} as never, - ), - ).rejects.toThrow('Invalid input for tool "write_document"'); - }); }); diff --git a/packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts b/packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts new file mode 100644 index 0000000..b870855 --- /dev/null +++ b/packages/extensions/export-html/src/__tests__/exportHtml.part2.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { createEditor } from "@pen/core"; +import type { DocumentOp } from "@pen/types"; +import { htmlExporter } from "../exporter"; + +type InsertTableCellTextOp = Extract; +type FormatTableCellTextOp = Extract; +type UpdateTableColumnsOp = Extract; +type DatabaseInsertRowOp = Extract; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function editorWithBlocks(ops: Parameters["apply"]>[0]) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + return editor; +} + +function editorWithTable( + insertOp: Parameters["apply"]>[0][0], + cellOps: Parameters["apply"]>[0], +) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply([insertOp]); + if (cellOps.length > 0) { + editor.apply(cellOps); + } + return editor; +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +) { + const seedEditor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + seed(seedEditor); + + const document = seedEditor.internals.crdtDoc; + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + + const editor = createEditor({ + document, + preset: noDefaultExtensionsPreset, + }); + seedEditor.destroy(); + return editor; +} + +describe("@pen/export-html", () => { + it("supports resolved suggestion export inside table cells", () => { + const editor = editorWithTable( + { + type: "insert-block", + blockId: "t3", + blockType: "table", + props: { hasHeaderRow: false }, + position: "last", + }, + [ + { + type: "insert-table-cell-text", + blockId: "t3", + row: 0, + col: 0, + offset: 0, + text: "ab", + } as InsertTableCellTextOp, + { + type: "format-table-cell-text", + blockId: "t3", + row: 0, + col: 0, + offset: 0, + length: 1, + marks: { + suggestion: { id: "cell-insert", action: "insert" }, + }, + } as FormatTableCellTextOp, + { + type: "format-table-cell-text", + blockId: "t3", + row: 0, + col: 0, + offset: 1, + length: 1, + marks: { + suggestion: { id: "cell-delete", action: "delete" }, + }, + } as FormatTableCellTextOp, + ], + ); + + const rawHtml = htmlExporter.export(editor); + expect(rawHtml).toContain('a'); + expect(rawHtml).toContain('b'); + + const resolvedHtml = htmlExporter.export(editor, { + includeSuggestions: false, + }); + expect(resolvedHtml).toContain("a"); + expect(resolvedHtml).not.toContain("b<"); + + editor.destroy(); + }); + + it("preserves seeded structured and hidden blocks when exporting flow documents", () => { + const editor = createFlowEditorFromSeededDocument((seedEditor) => { + seedEditor.apply([ + { + type: "insert-block", + blockId: "db1", + blockType: "database", + props: {}, + position: "last", + }, + { + type: "update-table-columns", + blockId: "db1", + columns: [{ id: "name", title: "Name", type: "text" }], + } as UpdateTableColumnsOp, + { + type: "database-insert-row", + blockId: "db1", + rowId: "row-1", + values: { name: "Alice" }, + } as DatabaseInsertRowOp, + { + type: "insert-block", + blockId: "sub-1", + blockType: "subdocument", + props: { subdocumentGuid: "nested-guid" }, + position: "last", + }, + ]); + }); + + const html = htmlExporter.export(editor); + + expect(editor.documentProfile).toBe("flow"); + expect(html).toContain("data-pen-database="); + expect(html).toContain(">Alice"); + expect(html).toContain('data-pen-subdocument="'); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/export-html/src/__tests__/exportHtml.test.ts b/packages/extensions/export-html/src/__tests__/exportHtml.test.ts index 19d3299..19cc5de 100644 --- a/packages/extensions/export-html/src/__tests__/exportHtml.test.ts +++ b/packages/extensions/export-html/src/__tests__/exportHtml.test.ts @@ -415,102 +415,4 @@ describe("@pen/export-html", () => { editor.destroy(); }); - it("supports resolved suggestion export inside table cells", () => { - const editor = editorWithTable( - { - type: "insert-block", - blockId: "t3", - blockType: "table", - props: { hasHeaderRow: false }, - position: "last", - }, - [ - { - type: "insert-table-cell-text", - blockId: "t3", - row: 0, - col: 0, - offset: 0, - text: "ab", - } as InsertTableCellTextOp, - { - type: "format-table-cell-text", - blockId: "t3", - row: 0, - col: 0, - offset: 0, - length: 1, - marks: { - suggestion: { id: "cell-insert", action: "insert" }, - }, - } as FormatTableCellTextOp, - { - type: "format-table-cell-text", - blockId: "t3", - row: 0, - col: 0, - offset: 1, - length: 1, - marks: { - suggestion: { id: "cell-delete", action: "delete" }, - }, - } as FormatTableCellTextOp, - ], - ); - - const rawHtml = htmlExporter.export(editor); - expect(rawHtml).toContain('a'); - expect(rawHtml).toContain('b'); - - const resolvedHtml = htmlExporter.export(editor, { - includeSuggestions: false, - }); - expect(resolvedHtml).toContain("a"); - expect(resolvedHtml).not.toContain("b<"); - - editor.destroy(); - }); - - it("preserves seeded structured and hidden blocks when exporting flow documents", () => { - const editor = createFlowEditorFromSeededDocument((seedEditor) => { - seedEditor.apply([ - { - type: "insert-block", - blockId: "db1", - blockType: "database", - props: {}, - position: "last", - }, - { - type: "update-table-columns", - blockId: "db1", - columns: [{ id: "name", title: "Name", type: "text" }], - } as UpdateTableColumnsOp, - { - type: "database-insert-row", - blockId: "db1", - rowId: "row-1", - values: { name: "Alice" }, - } as DatabaseInsertRowOp, - { - type: "insert-block", - blockId: "sub-1", - blockType: "subdocument", - props: { subdocumentGuid: "nested-guid" }, - position: "last", - }, - ]); - }); - - const html = htmlExporter.export(editor); - - expect(editor.documentProfile).toBe("flow"); - expect(html).toContain("data-pen-database="); - expect(html).toContain(">Alice"); - expect(html).toContain('data-pen-subdocument="'); - - editor.destroy(); - }); }); diff --git a/packages/extensions/export-json/README.md b/packages/extensions/export-json/README.md index 8f6302b..6976c83 100644 --- a/packages/extensions/export-json/README.md +++ b/packages/extensions/export-json/README.md @@ -11,7 +11,27 @@ pnpm add @pen/export-json ## What It Provides - `jsonExporter` for machine-readable document export +- `textExporter` and `exportPlainText()` for plain text export - `jsonImporter` for importing Pen JSON documents - shared JSON document types for integration code Use this package for persistence, interchange, and deterministic round-tripping of supported Pen document content. + +## Plain Text + +```ts +import { + exportEditorToText, + exportPenDocumentToText, + exportPlainText, +} from "@pen/export-json"; + +const textFromEditor = exportEditorToText(editor); +const plainText = exportPlainText(editor); +const textFromJson = exportPenDocumentToText(documentJson, { + excludeBlockTypes: ["quote"], + separator: " ", +}); +``` + +Hosts can filter block types, render app-specific inline nodes, and extract database block text while keeping product delivery policy outside Pen. diff --git a/packages/extensions/export-json/src/__tests__/textExporter.test.ts b/packages/extensions/export-json/src/__tests__/textExporter.test.ts new file mode 100644 index 0000000..87a0200 --- /dev/null +++ b/packages/extensions/export-json/src/__tests__/textExporter.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { exportPenDocumentToText } from "../textExporter"; +import type { PenDocumentJSON } from "../types"; + +describe("exportPenDocumentToText", () => { + it("exports nested block text in document order", () => { + const document: PenDocumentJSON = { + version: 1, + blocks: [ + { + id: "one", + type: "paragraph", + props: {}, + content: { text: "One" }, + children: [ + { + id: "child", + type: "paragraph", + props: {}, + content: { text: "Child" }, + }, + ], + }, + { + id: "two", + type: "paragraph", + props: {}, + content: { text: "Two" }, + }, + ], + }; + + expect(exportPenDocumentToText(document)).toBe("One\nChild\nTwo"); + }); + + it("supports host-owned block exclusion and inline node rendering", () => { + const document: PenDocumentJSON = { + version: 1, + blocks: [ + { + id: "body", + type: "paragraph", + props: {}, + content: { + text: "Hello ", + segments: [ + { type: "text", text: "Hello " }, + { + type: "node", + nodeType: "mention", + props: { label: "Ada" }, + }, + ], + }, + }, + { + id: "quote", + type: "emailQuote", + props: {}, + content: { text: "Quoted" }, + }, + ], + }; + + expect( + exportPenDocumentToText(document, { + excludeBlockTypes: ["emailQuote"], + renderInlineNode(segment) { + const label = segment.props?.label; + return segment.nodeType === "mention" && + typeof label === "string" + ? `@${label}` + : ""; + }, + }), + ).toBe("Hello @Ada"); + }); + + it("exports database block text in stable column order", () => { + const document: PenDocumentJSON = { + version: 1, + blocks: [ + { + id: "db", + type: "database", + props: {}, + database: { + title: "Tasks", + columns: [ + { id: "name", type: "text", title: "Name" }, + { id: "owner", type: "text", title: "Owner" }, + ], + rows: [ + { + id: "one", + values: { name: "Plan", owner: "Ada" }, + }, + { + id: "two", + values: { name: "Ship", owner: "Grace" }, + }, + ], + }, + }, + ], + }; + + expect(exportPenDocumentToText(document)).toBe( + "Tasks\nPlan\tAda\nShip\tGrace", + ); + }); +}); diff --git a/packages/extensions/export-json/src/index.ts b/packages/extensions/export-json/src/index.ts index 1dbc423..b799fb6 100644 --- a/packages/extensions/export-json/src/index.ts +++ b/packages/extensions/export-json/src/index.ts @@ -1,17 +1,24 @@ export { jsonExporter, exportEditorToJson } from "./exporter"; +export { + exportEditorToText, + exportPenDocumentToText, + exportPlainText, + textExporter, +} from "./textExporter"; export { jsonImporter, parseJsonDocument } from "./importer"; export { - PEN_DOCUMENT_JSON_VERSION, - isSupportedPenDocumentVersion, + PEN_DOCUMENT_JSON_VERSION, + isSupportedPenDocumentVersion, } from "./schema"; export type { - PenBlockJSON, - PenDatabaseJSON, - PenDocumentJSON, - PenInlineContentJSON, - PenInlineNodeSegmentJSON, - PenInlineSegmentJSON, - PenInlineTextSegmentJSON, - PenJsonExportExtraOptions, - PenMarkJSON, + PenBlockJSON, + PenDatabaseJSON, + PenDocumentJSON, + PenInlineContentJSON, + PenInlineNodeSegmentJSON, + PenInlineSegmentJSON, + PenInlineTextSegmentJSON, + PenJsonExportExtraOptions, + PenMarkJSON, } from "./types"; +export type { PenTextExportExtraOptions } from "./textExporter"; diff --git a/packages/extensions/export-json/src/textExporter.ts b/packages/extensions/export-json/src/textExporter.ts new file mode 100644 index 0000000..bc77ca7 --- /dev/null +++ b/packages/extensions/export-json/src/textExporter.ts @@ -0,0 +1,124 @@ +import type { Editor, Exporter, ExportOptions } from "@pen/types"; +import { exportEditorToJson } from "./exporter"; +import type { + PenBlockJSON, + PenDocumentJSON, + PenInlineNodeSegmentJSON, + PenInlineSegmentJSON, +} from "./types"; + +const ZERO_WIDTH_SPACE = "\u200B"; +const DEFAULT_SEPARATOR = "\n"; + +export type PenTextExportExtraOptions = Record & { + excludeBlockTypes?: string[]; + includeBlockTypes?: string[]; + separator?: string; + renderInlineNode?: (segment: PenInlineNodeSegmentJSON) => string; +}; + +export const textExporter: Exporter = { + name: "text", + mimeType: "text/plain", + fileExtension: ".txt", + + export( + editor: Editor, + options?: ExportOptions, + ): string { + return exportEditorToText(editor, options); + }, +}; + +export function exportEditorToText( + editor: Editor, + options?: ExportOptions, +): string { + return exportPenDocumentToText(exportEditorToJson(editor), options?.extra); +} + +export function exportPlainText( + editor: Editor, + options?: ExportOptions, +): string { + return exportEditorToText(editor, options); +} + +export function exportPenDocumentToText( + document: PenDocumentJSON, + options: PenTextExportExtraOptions = {}, +): string { + const separator = options.separator ?? DEFAULT_SEPARATOR; + return document.blocks + .flatMap((block) => renderBlockText(block, options)) + .join(separator); +} + +function renderBlockText( + block: PenBlockJSON, + options: PenTextExportExtraOptions, +): string[] { + if (options.excludeBlockTypes?.includes(block.type)) { + return []; + } + if ( + options.includeBlockTypes && + !options.includeBlockTypes.includes(block.type) + ) { + return []; + } + + const ownText = renderInlineContentText(block, options); + const databaseTexts = renderDatabaseText(block); + const childTexts = + block.children?.flatMap((child) => renderBlockText(child, options)) ?? + []; + + return [ownText, ...databaseTexts, ...childTexts].filter( + (text) => text.length > 0, + ); +} + +function renderInlineContentText( + block: PenBlockJSON, + options: PenTextExportExtraOptions, +): string { + if (block.content?.segments?.length) { + return block.content.segments + .map((segment) => renderInlineSegmentText(segment, options)) + .join("") + .replaceAll(ZERO_WIDTH_SPACE, ""); + } + + return (block.content?.text ?? "").replaceAll(ZERO_WIDTH_SPACE, ""); +} + +function renderInlineSegmentText( + segment: PenInlineSegmentJSON, + options: PenTextExportExtraOptions, +): string { + if (segment.type === "text") { + return segment.text.replaceAll(ZERO_WIDTH_SPACE, ""); + } + + return options.renderInlineNode?.(segment) ?? ""; +} + +function renderDatabaseText(block: PenBlockJSON): string[] { + if (!block.database) { + return []; + } + + const columnIds = block.database.columns.map((column) => column.id); + const title = block.database.title?.trim(); + const rows = block.database.rows + .map((row) => + columnIds + .map((columnId) => row.values[columnId]) + .filter((value): value is string => Boolean(value?.trim())) + .join("\t"), + ) + .filter((rowText) => rowText.length > 0); + + return [title, ...rows].filter((text): text is string => Boolean(text)); +} diff --git a/packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts new file mode 100644 index 0000000..0a329ac --- /dev/null +++ b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.part2.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { + blocksToOps, + createEditor, + type PendingBlock, +} from "@pen/core"; +import type { DocumentOp } from "@pen/types"; +import { markdownExporter } from "../exporter"; + +type InsertTableCellTextOp = Extract; +type FormatTableCellTextOp = Extract; +type UpdateTableColumnsOp = Extract; +type DatabaseInsertRowOp = Extract; +type InsertBlockOp = Extract; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +function editorWithBlocks(ops: Parameters["apply"]>[0]) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + return editor; +} + +function editorWithTable( + insertOp: Parameters["apply"]>[0][0], + cellOps: Parameters["apply"]>[0], +) { + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply([insertOp]); + if (cellOps.length > 0) { + editor.apply(cellOps); + } + return editor; +} + +function createFlowEditorFromSeededDocument( + seed: (editor: ReturnType) => void, +) { + const seedEditor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + seed(seedEditor); + + const document = seedEditor.internals.crdtDoc; + seedEditor.internals.adapter.setDocumentProfile?.(document, "flow"); + + const editor = createEditor({ + document, + preset: noDefaultExtensionsPreset, + }); + seedEditor.destroy(); + return editor; +} + +describe("table markdown round-trip", () => { + it("import → editor → export produces equivalent markdown", () => { + const inputBlocks: PendingBlock[] = [ + { + type: "table", + props: { hasHeaderRow: true, hasHeaderColumn: false }, + children: [ + { + type: "__table_row", + props: { _rowIndex: 0 }, + children: [ + { type: "__table_cell", props: {}, content: "Name" }, + { type: "__table_cell", props: {}, content: "Value" }, + ], + }, + { + type: "__table_row", + props: { _rowIndex: 1 }, + children: [ + { type: "__table_cell", props: {}, content: "foo" }, + { type: "__table_cell", props: {}, content: "42" }, + ], + }, + ], + }, + ]; + + const ops = blocksToOps(inputBlocks); + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + + const tableBlockId = (ops[0] as InsertBlockOp).blockId; + const cell00 = editor.getBlock(tableBlockId)?.tableCell(0, 0); + const cell01 = editor.getBlock(tableBlockId)?.tableCell(0, 1); + const cell10 = editor.getBlock(tableBlockId)?.tableCell(1, 0); + const cell11 = editor.getBlock(tableBlockId)?.tableCell(1, 1); + expect(cell00?.textContent()).toBe("Name"); + expect(cell01?.textContent()).toBe("Value"); + expect(cell10?.textContent()).toBe("foo"); + expect(cell11?.textContent()).toBe("42"); + + const md = markdownExporter.export(editor); + expect(md).toContain("| Name | Value |"); + expect(md).toContain("| --- | --- |"); + expect(md).toContain("| foo | 42 |"); + + editor.destroy(); + }); + + it("round-trips a 2-column table through import and export", () => { + const inputBlocks: PendingBlock[] = [ + { + type: "table", + props: { hasHeaderRow: true, hasHeaderColumn: false }, + children: [ + { + type: "__table_row", + props: { _rowIndex: 0 }, + children: [ + { type: "__table_cell", props: {}, content: "X" }, + { type: "__table_cell", props: {}, content: "Y" }, + ], + }, + { + type: "__table_row", + props: { _rowIndex: 1 }, + children: [ + { type: "__table_cell", props: {}, content: "10" }, + { type: "__table_cell", props: {}, content: "20" }, + ], + }, + ], + }, + ]; + + const ops = blocksToOps(inputBlocks); + const editor = createEditor({ + preset: noDefaultExtensionsPreset, + }); + editor.apply(ops); + + const block = editor.getBlock((ops[0] as InsertBlockOp).blockId); + expect(block?.tableRowCount()).toBe(2); + expect(block?.tableColumnCount()).toBe(2); + + const md = markdownExporter.export(editor); + expect(md).toContain("| X | Y |"); + expect(md).toContain("| --- | --- |"); + expect(md).toContain("| 10 | 20 |"); + + editor.destroy(); + }); +}); diff --git a/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts index ac32bf9..e0e73f7 100644 --- a/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts +++ b/packages/extensions/export-markdown/src/__tests__/exportMarkdown.test.ts @@ -418,99 +418,3 @@ describe("@pen/export-markdown", () => { editor.destroy(); }); }); - -describe("table markdown round-trip", () => { - it("import → editor → export produces equivalent markdown", () => { - const inputBlocks: PendingBlock[] = [ - { - type: "table", - props: { hasHeaderRow: true, hasHeaderColumn: false }, - children: [ - { - type: "__table_row", - props: { _rowIndex: 0 }, - children: [ - { type: "__table_cell", props: {}, content: "Name" }, - { type: "__table_cell", props: {}, content: "Value" }, - ], - }, - { - type: "__table_row", - props: { _rowIndex: 1 }, - children: [ - { type: "__table_cell", props: {}, content: "foo" }, - { type: "__table_cell", props: {}, content: "42" }, - ], - }, - ], - }, - ]; - - const ops = blocksToOps(inputBlocks); - const editor = createEditor({ - preset: noDefaultExtensionsPreset, - }); - editor.apply(ops); - - const tableBlockId = (ops[0] as InsertBlockOp).blockId; - const cell00 = editor.getBlock(tableBlockId)?.tableCell(0, 0); - const cell01 = editor.getBlock(tableBlockId)?.tableCell(0, 1); - const cell10 = editor.getBlock(tableBlockId)?.tableCell(1, 0); - const cell11 = editor.getBlock(tableBlockId)?.tableCell(1, 1); - expect(cell00?.textContent()).toBe("Name"); - expect(cell01?.textContent()).toBe("Value"); - expect(cell10?.textContent()).toBe("foo"); - expect(cell11?.textContent()).toBe("42"); - - const md = markdownExporter.export(editor); - expect(md).toContain("| Name | Value |"); - expect(md).toContain("| --- | --- |"); - expect(md).toContain("| foo | 42 |"); - - editor.destroy(); - }); - - it("round-trips a 2-column table through import and export", () => { - const inputBlocks: PendingBlock[] = [ - { - type: "table", - props: { hasHeaderRow: true, hasHeaderColumn: false }, - children: [ - { - type: "__table_row", - props: { _rowIndex: 0 }, - children: [ - { type: "__table_cell", props: {}, content: "X" }, - { type: "__table_cell", props: {}, content: "Y" }, - ], - }, - { - type: "__table_row", - props: { _rowIndex: 1 }, - children: [ - { type: "__table_cell", props: {}, content: "10" }, - { type: "__table_cell", props: {}, content: "20" }, - ], - }, - ], - }, - ]; - - const ops = blocksToOps(inputBlocks); - const editor = createEditor({ - preset: noDefaultExtensionsPreset, - }); - editor.apply(ops); - - const block = editor.getBlock((ops[0] as InsertBlockOp).blockId); - expect(block?.tableRowCount()).toBe(2); - expect(block?.tableColumnCount()).toBe(2); - - const md = markdownExporter.export(editor); - expect(md).toContain("| X | Y |"); - expect(md).toContain("| --- | --- |"); - expect(md).toContain("| 10 | 20 |"); - - editor.destroy(); - }); -}); diff --git a/packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts b/packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts new file mode 100644 index 0000000..797b231 --- /dev/null +++ b/packages/extensions/import-html/src/__tests__/importHtml.part2.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect } from "vitest"; +import { blocksToOps, createEditor } from "@pen/core"; +import type { HTMLImportElement, SchemaRegistry } from "@pen/types"; +import { createDefaultSchema } from "@pen/schema-default"; +import { htmlExporter } from "@pen/export-html"; +import { htmlImporter, parseHtmlToBlocks } from "../importer"; +import { sanitizeHTML } from "../sanitize"; +import { parseHTML } from "../domAdapter"; +import { domToBlocks } from "../domToBlocks"; +import { parseInlineContent } from "../inlineParser"; +import type { DOMNode } from "../domAdapter"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const stubRegistry: SchemaRegistry = { + resolve: () => null, + resolveInline: () => null, + resolveApp: () => null, + resolveLayout: () => null, + allBlocks: () => [], + allInlines: () => [], + allApps: () => [], + allBlockDisplays: () => [], +}; + +const defaultRegistry = createDefaultSchema(); + +function convert(html: string, registry: SchemaRegistry = stubRegistry) { + const sanitized = sanitizeHTML(html); + const dom = parseHTML(sanitized); + return domToBlocks(dom, registry); +} + +function databaseEditor() { + const editor = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + editor.apply([{ + type: "insert-block", + blockId: "d1", + blockType: "database", + props: { title: "Roadmap", dataSource: "local" }, + position: "last", + }]); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "name", title: "Name", type: "text" }, + { + id: "tags", + title: "Tags", + type: "multiSelect", + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "feature", value: "Feature", color: "blue" }, + ], + }, + { id: "done", title: "Done", type: "checkbox" }, + ], + }]); + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "roadmap-1", + values: { + name: "Ship importer", + tags: JSON.stringify(["Feature"]), + done: "false", + }, + }]); + editor.apply([{ + type: "database-update-view", + blockId: "d1", + patch: { + title: "Main", + type: "table", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + }, + }]); + return editor; +} + +describe("@pen/import-html dom-to-blocks", () => { + it("heading + paragraph (AC 28)", () => { + const blocks = convert("

Title

Body

"); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "heading", + props: { level: 1 }, + content: "Title", + }); + expect(blocks[1]).toMatchObject({ + type: "paragraph", + content: "Body", + }); + }); + + it("script tag is stripped (AC 29)", () => { + const blocks = convert('

safe

'); + + const types = blocks.map((b) => b.type); + expect(types).not.toContain("script"); + expect(blocks.some((b) => b.content === "safe")).toBe(true); + }); + + it("event handler stripped, text preserved (AC 30)", () => { + const blocks = convert('
text
'); + + expect(blocks.length).toBeGreaterThanOrEqual(1); + const hasText = blocks.some( + (b) => b.content?.includes("text"), + ); + expect(hasText).toBe(true); + }); + + it("bold mark from (AC 32)", () => { + const blocks = convert("

bold

"); + + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toBe("bold"); + expect(blocks[0].marks?.some((m) => m.type === "bold")).toBe(true); + }); + + it("italic mark from (AC 33)", () => { + const blocks = convert("

italic

"); + + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toBe("italic"); + expect(blocks[0].marks?.some((m) => m.type === "italic")).toBe(true); + }); + + it("link mark with href (AC 34)", () => { + const blocks = convert('

text

'); + + expect(blocks).toHaveLength(1); + expect(blocks[0].content).toBe("text"); + const linkMark = blocks[0].marks?.find((m) => m.type === "link"); + expect(linkMark).toBeDefined(); + expect(linkMark!.props!.href).toBe("https://example.com"); + }); + + it("bullet list items (AC 35)", () => { + const blocks = convert("
  • a
  • b
"); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "bulletListItem", + content: "a", + }); + expect(blocks[1]).toMatchObject({ + type: "bulletListItem", + content: "b", + }); + }); + + it("numbered list items (AC 36)", () => { + const blocks = convert("
  1. a
  2. b
"); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "numberedListItem", + content: "a", + }); + expect(blocks[1]).toMatchObject({ + type: "numberedListItem", + content: "b", + }); + }); + + it("nested list with indent (AC 37)", () => { + const blocks = convert( + "
  • a
    • b
", + ); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "bulletListItem", + content: "a", + props: { indent: 0 }, + }); + expect(blocks[1]).toMatchObject({ + type: "bulletListItem", + content: "b", + props: { indent: 1 }, + }); + }); + + it("code block with language (AC 38)", () => { + const blocks = convert( + '
const x = 1;
', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "codeBlock", + props: { language: "js" }, + content: "const x = 1;", + }); + }); + + it("hr → divider (AC 39)", () => { + const blocks = convert("
"); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("divider"); + }); + + it("image with props (AC 40)", () => { + const blocks = convert('text'); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "image", + props: { src: "url", alt: "text", caption: "cap" }, + }); + }); + + it("heading levels 1-6", () => { + const blocks = convert( + "

H1

H2

H3

H4

H5
H6
", + ); + + expect(blocks).toHaveLength(6); + for (let i = 0; i < 6; i++) { + expect(blocks[i].type).toBe("heading"); + expect(blocks[i].props.level).toBe(i + 1); + } + }); + + it("div content is unwrapped (block container)", () => { + const blocks = convert("

inner

"); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "paragraph", + content: "inner", + }); + }); + + it("table with header (AC 40 extension)", () => { + const blocks = convert( + "
AB
12
", + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("table"); + expect(blocks[0].props.hasHeaderRow).toBe(true); + expect(blocks[0].children).toHaveLength(2); + }); + + it("round-trips exported database HTML back into a database block", async () => { + const source = databaseEditor(); + const html = await htmlExporter.export(source); + + const blocks = convert(html, defaultRegistry); + const databaseBlock = blocks.find((block) => block.type === "database"); + expect(databaseBlock).toMatchObject({ + type: "database", + props: { title: "Roadmap", dataSource: "local" }, + }); + expect(databaseBlock?.database).toEqual( + expect.objectContaining({ + primaryViewId: expect.any(String), + columns: [ + expect.objectContaining({ id: "name", title: "Name", type: "text" }), + expect.objectContaining({ id: "tags", title: "Tags", type: "multiSelect" }), + expect.objectContaining({ id: "done", title: "Done", type: "checkbox" }), + ], + rows: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + values: { + name: "Ship importer", + tags: JSON.stringify(["feature"]), + done: "false", + }, + }), + ]), + }), + ); + + const target = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + const ops = blocksToOps(blocks); + target.apply(ops, { origin: "import", undoGroup: true }); + const imported = Array.from(target.documentState.allBlocks()).find( + (block) => block.type === "database", + ); + expect(imported?.props.title).toBe("Roadmap"); + expect(imported?.tableColumns().map((column) => column.id)).toEqual(["name", "tags", "done"]); + expect(imported?.tableRow(0)?.id).toEqual(expect.any(String)); + expect(imported?.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["feature"])); + expect(imported?.databaseActiveView()).toEqual( + expect.objectContaining({ + title: "Main", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + }), + ); + + source.destroy(); + target.destroy(); + }); + + it("preserves intentionally empty database rows when round-tripping HTML", async () => { + const source = databaseEditor(); + source.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "empty-row", + }]); + const html = await htmlExporter.export(source); + + const blocks = convert(html, defaultRegistry); + const databaseBlock = blocks.find((block) => block.type === "database"); + expect(databaseBlock?.database?.rows).toEqual([ + expect.objectContaining({ + values: { + name: "Ship importer", + tags: JSON.stringify(["feature"]), + done: "false", + }, + }), + { + id: "empty-row", + values: { + name: "", + tags: "", + done: "", + }, + }, + ]); + + const target = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + target.apply(blocksToOps(blocks), { origin: "import", undoGroup: true }); + + const imported = Array.from(target.documentState.allBlocks()).find( + (block) => block.type === "database", + ); + expect(imported?.tableRowCount()).toBe(2); + expect(imported?.tableRow(1)?.id).toBe("empty-row"); + expect(imported?.tableCell(1, 0)?.textContent()).toBe(""); + expect(imported?.tableCell(1, 1)?.textContent()).toBe(""); + expect(imported?.tableCell(1, 2)?.textContent()).toBe(""); + + source.destroy(); + target.destroy(); + }); + + it("imports typed HTML tables as database blocks without Pen payload", () => { + const blocks = convert( + '
NameStatus
Ship ittodo
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "database", + props: { title: "Untitled", dataSource: "local" }, + database: { + columns: [ + expect.objectContaining({ id: "name", title: "Name", type: "text" }), + expect.objectContaining({ + id: "status", + title: "Status", + type: "select", + options: [{ id: "todo", value: "Todo" }], + }), + ], + rows: [ + expect.objectContaining({ + values: { name: "Ship it", status: "todo" }, + }), + ], + }, + }); + }); + + it("coerces select labels to option IDs during typed HTML import", () => { + const blocks = convert( + '
NameStatus
Task ATodo
Task Bdone
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + const rows = blocks[0].database!.rows; + expect(rows[0].values.status).toBe("todo"); + expect(rows[1].values.status).toBe("done"); + }); + + it("coerces multiSelect labels to option IDs during typed HTML import", () => { + const blocks = convert( + '
Tags
Bug, Feature
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + const rows = blocks[0].database!.rows; + expect(rows[0].values.tags).toBe(JSON.stringify(["bug", "feat"])); + }); + + it("preserves hidden and readonly false values during typed HTML import", () => { + const blocks = convert( + '
A
x
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + const col = blocks[0].database!.columns[0]; + expect(col.hidden).toBe(false); + expect(col.readonly).toBe(false); + }); + + it("blocksToOps generates correct ops (AC 41)", () => { + const blocks = convert("

Title

bold

"); + const ops = blocksToOps(blocks); + + const insertBlocks = ops.filter((o) => o.type === "insert-block"); + expect(insertBlocks).toHaveLength(2); + + const formatTexts = ops.filter((o) => o.type === "format-text"); + expect(formatTexts.length).toBeGreaterThan(0); + expect(formatTexts[0].marks).toHaveProperty("bold"); + }); + + it("inline-only at block level wraps in paragraph", () => { + const dom = parseHTML("bold at root"); + const blocks = domToBlocks(dom, stubRegistry); + + expect(blocks.some((b) => b.type === "paragraph" && b.content?.includes("bold at root"))).toBe(true); + }); + +}); diff --git a/packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts b/packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts new file mode 100644 index 0000000..41c924d --- /dev/null +++ b/packages/extensions/import-html/src/__tests__/importHtml.part3.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect } from "vitest"; +import { blocksToOps, createEditor } from "@pen/core"; +import type { HTMLImportElement, SchemaRegistry } from "@pen/types"; +import { createDefaultSchema } from "@pen/schema-default"; +import { htmlExporter } from "@pen/export-html"; +import { htmlImporter, parseHtmlToBlocks } from "../importer"; +import { sanitizeHTML } from "../sanitize"; +import { parseHTML } from "../domAdapter"; +import { domToBlocks } from "../domToBlocks"; +import { parseInlineContent } from "../inlineParser"; +import type { DOMNode } from "../domAdapter"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const stubRegistry: SchemaRegistry = { + resolve: () => null, + resolveInline: () => null, + resolveApp: () => null, + resolveLayout: () => null, + allBlocks: () => [], + allInlines: () => [], + allApps: () => [], + allBlockDisplays: () => [], +}; + +const defaultRegistry = createDefaultSchema(); + +function convert(html: string, registry: SchemaRegistry = stubRegistry) { + const sanitized = sanitizeHTML(html); + const dom = parseHTML(sanitized); + return domToBlocks(dom, registry); +} + +function databaseEditor() { + const editor = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + editor.apply([{ + type: "insert-block", + blockId: "d1", + blockType: "database", + props: { title: "Roadmap", dataSource: "local" }, + position: "last", + }]); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "name", title: "Name", type: "text" }, + { + id: "tags", + title: "Tags", + type: "multiSelect", + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "feature", value: "Feature", color: "blue" }, + ], + }, + { id: "done", title: "Done", type: "checkbox" }, + ], + }]); + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "roadmap-1", + values: { + name: "Ship importer", + tags: JSON.stringify(["Feature"]), + done: "false", + }, + }]); + editor.apply([{ + type: "database-update-view", + blockId: "d1", + patch: { + title: "Main", + type: "table", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + }, + }]); + return editor; +} + +describe("@pen/import-html dom-to-blocks", () => { + it("server-side parsing produces identical blocks as browser-side for same input (AC 43)", () => { + const inputs = [ + "

Title

Body

", + "
  • a
  • b
", + '
const x = 1;
', + "
", + 'text', + "

bold and italic

", + ]; + + for (const html of inputs) { + const sanitized = sanitizeHTML(html); + const dom = parseHTML(sanitized); + const blocks = domToBlocks(dom, stubRegistry); + + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + expect(block.type).toBeTruthy(); + expect(block.props).toBeDefined(); + } + } + }); + + it("
→ toggle block via schema fromHTML", () => { + const blocks = convert( + "
Toggle title
", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + props: { open: false }, + content: "Toggle title", + }); + }); + + it("passes the public HTML import element to schema fromHTML hooks", () => { + let receivedElement: HTMLImportElement | null = null; + const registry: SchemaRegistry = { + ...stubRegistry, + allBlocks: () => [{ + type: "custom", + propSchema: {}, + content: "inline", + serialize: { + fromHTML(element: HTMLImportElement) { + receivedElement = element; + if (element.tagName !== "div") { + return null; + } + return { + type: "paragraph", + props: {}, + content: element.getAttribute("data-title") ?? "", + }; + }, + }, + }], + resolve: (type) => (type === "custom" ? registry.allBlocks()[0] : null), + }; + + const blocks = convert('
', registry); + + expect(receivedElement).toMatchObject({ + type: "element", + tagName: "div", + attributes: { "data-title": "From hook" }, + }); + if (!receivedElement) { + throw new Error("Expected schema fromHTML hook to receive an element"); + } + const hookElement = receivedElement as unknown as HTMLImportElement; + expect(hookElement.getAttribute("data-title")).toBe("From hook"); + expect(hookElement.hasAttribute("data-title")).toBe(true); + expect(blocks).toMatchObject([ + { + type: "paragraph", + content: "From hook", + }, + ]); + }); + + it("
→ toggle block with open=true", () => { + const blocks = convert( + '
Open toggle
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + props: { open: true }, + content: "Open toggle", + }); + }); + + it("preserves inline formatting inside an HTML toggle summary", () => { + const blocks = convert( + "
Bold and italic
", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + content: "Bold and italic", + }); + expect(blocks[0].marks?.some((mark) => mark.type === "bold")).toBe(true); + expect(blocks[0].marks?.some((mark) => mark.type === "italic")).toBe(true); + }); + + it("
→ callout block", () => { + const blocks = convert( + '
Be careful
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "warning" }, + content: "Be careful", + }); + }); + + it("
→ callout block", () => { + const blocks = convert( + '
Something failed
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "error" }, + content: "Something failed", + }); + }); + + it("
→ callout block", () => { + const blocks = convert( + '
FYI
', + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "info" }, + content: "FYI", + }); + }); + + it("
    preserves start value on first list item", () => { + const blocks = convert('
    1. fifth
    2. sixth
    '); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "numberedListItem", + content: "fifth", + props: { indent: 0, start: 5 }, + }); + expect(blocks[1]).toMatchObject({ + type: "numberedListItem", + content: "sixth", + props: { indent: 0 }, + }); + }); + + it("
      without start attribute does not set start", () => { + const blocks = convert("
      1. first
      "); + + expect(blocks).toHaveLength(1); + expect(blocks[0].props.start).toBeUndefined(); + }); + + it("keeps parseHtmlToBlocks parse-only in flow documents", () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const blocks = parseHtmlToBlocks(`${html}

      Allowed

      `, editor); + + expect(blocks.some((block) => block.type === "database")).toBe(true); + expect(blocks.some((block) => block.type === "heading")).toBe(true); + + source.destroy(); + editor.destroy(); + }); + + it("does not emit normalization diagnostics during parseHtmlToBlocks", () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + parseHtmlToBlocks(`${html}

      Allowed

      `, editor); + + expect(diagnostics).toEqual([]); + + source.destroy(); + editor.destroy(); + }); + + it("filters flow-disallowed blocks during direct HTML import into flow documents", async () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + await htmlImporter.import(`${html}

      Allowed

      `, editor); + + const blockOrder = editor.documentState.blockOrder; + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), + ).toBe(true); + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), + ).toBe(false); + + source.destroy(); + editor.destroy(); + }); + + it("returns a structured import result for HTML imports with normalization", async () => { + const source = databaseEditor(); + const html = htmlExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const result = await htmlImporter.import(`${html}

      Allowed

      `, editor); + + expect(result).toEqual({ + parsedTopLevelBlockCount: 3, + importedTopLevelBlockCount: 2, + droppedBlockCount: 1, + droppedBlockTypes: ["database"], + normalized: true, + }); + + source.destroy(); + editor.destroy(); + }); +}); diff --git a/packages/extensions/import-html/src/__tests__/importHtml.test.ts b/packages/extensions/import-html/src/__tests__/importHtml.test.ts index 6f761d9..3f65cab 100644 --- a/packages/extensions/import-html/src/__tests__/importHtml.test.ts +++ b/packages/extensions/import-html/src/__tests__/importHtml.test.ts @@ -139,7 +139,6 @@ describe("sanitizeHTML", () => { expect(result).not.toContain("z-index:"); }); }); - describe("parseInlineContent", () => { it("extracts text from text nodes", () => { const node: DOMNode = { type: "text", textContent: "hello" }; @@ -198,623 +197,3 @@ describe("parseInlineContent", () => { expect(result.marks.some((m) => m.type === "italic")).toBe(true); }); }); - -describe("@pen/import-html dom-to-blocks", () => { - it("heading + paragraph (AC 28)", () => { - const blocks = convert("

      Title

      Body

      "); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "heading", - props: { level: 1 }, - content: "Title", - }); - expect(blocks[1]).toMatchObject({ - type: "paragraph", - content: "Body", - }); - }); - - it("script tag is stripped (AC 29)", () => { - const blocks = convert('

      safe

      '); - - const types = blocks.map((b) => b.type); - expect(types).not.toContain("script"); - expect(blocks.some((b) => b.content === "safe")).toBe(true); - }); - - it("event handler stripped, text preserved (AC 30)", () => { - const blocks = convert('
      text
      '); - - expect(blocks.length).toBeGreaterThanOrEqual(1); - const hasText = blocks.some( - (b) => b.content?.includes("text"), - ); - expect(hasText).toBe(true); - }); - - it("bold mark from (AC 32)", () => { - const blocks = convert("

      bold

      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].content).toBe("bold"); - expect(blocks[0].marks?.some((m) => m.type === "bold")).toBe(true); - }); - - it("italic mark from (AC 33)", () => { - const blocks = convert("

      italic

      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].content).toBe("italic"); - expect(blocks[0].marks?.some((m) => m.type === "italic")).toBe(true); - }); - - it("link mark with href (AC 34)", () => { - const blocks = convert('

      text

      '); - - expect(blocks).toHaveLength(1); - expect(blocks[0].content).toBe("text"); - const linkMark = blocks[0].marks?.find((m) => m.type === "link"); - expect(linkMark).toBeDefined(); - expect(linkMark!.props!.href).toBe("https://example.com"); - }); - - it("bullet list items (AC 35)", () => { - const blocks = convert("
      • a
      • b
      "); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "bulletListItem", - content: "a", - }); - expect(blocks[1]).toMatchObject({ - type: "bulletListItem", - content: "b", - }); - }); - - it("numbered list items (AC 36)", () => { - const blocks = convert("
      1. a
      2. b
      "); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "numberedListItem", - content: "a", - }); - expect(blocks[1]).toMatchObject({ - type: "numberedListItem", - content: "b", - }); - }); - - it("nested list with indent (AC 37)", () => { - const blocks = convert( - "
      • a
        • b
      ", - ); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "bulletListItem", - content: "a", - props: { indent: 0 }, - }); - expect(blocks[1]).toMatchObject({ - type: "bulletListItem", - content: "b", - props: { indent: 1 }, - }); - }); - - it("code block with language (AC 38)", () => { - const blocks = convert( - '
      const x = 1;
      ', - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "codeBlock", - props: { language: "js" }, - content: "const x = 1;", - }); - }); - - it("hr → divider (AC 39)", () => { - const blocks = convert("
      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].type).toBe("divider"); - }); - - it("image with props (AC 40)", () => { - const blocks = convert('text'); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "image", - props: { src: "url", alt: "text", caption: "cap" }, - }); - }); - - it("heading levels 1-6", () => { - const blocks = convert( - "

      H1

      H2

      H3

      H4

      H5
      H6
      ", - ); - - expect(blocks).toHaveLength(6); - for (let i = 0; i < 6; i++) { - expect(blocks[i].type).toBe("heading"); - expect(blocks[i].props.level).toBe(i + 1); - } - }); - - it("div content is unwrapped (block container)", () => { - const blocks = convert("

      inner

      "); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "paragraph", - content: "inner", - }); - }); - - it("table with header (AC 40 extension)", () => { - const blocks = convert( - "
      AB
      12
      ", - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0].type).toBe("table"); - expect(blocks[0].props.hasHeaderRow).toBe(true); - expect(blocks[0].children).toHaveLength(2); - }); - - it("round-trips exported database HTML back into a database block", async () => { - const source = databaseEditor(); - const html = await htmlExporter.export(source); - - const blocks = convert(html, defaultRegistry); - const databaseBlock = blocks.find((block) => block.type === "database"); - expect(databaseBlock).toMatchObject({ - type: "database", - props: { title: "Roadmap", dataSource: "local" }, - }); - expect(databaseBlock?.database).toEqual( - expect.objectContaining({ - primaryViewId: expect.any(String), - columns: [ - expect.objectContaining({ id: "name", title: "Name", type: "text" }), - expect.objectContaining({ id: "tags", title: "Tags", type: "multiSelect" }), - expect.objectContaining({ id: "done", title: "Done", type: "checkbox" }), - ], - rows: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - values: { - name: "Ship importer", - tags: JSON.stringify(["feature"]), - done: "false", - }, - }), - ]), - }), - ); - - const target = createEditor({ - schema: defaultRegistry, - preset: noDefaultExtensionsPreset, - }); - const ops = blocksToOps(blocks); - target.apply(ops, { origin: "import", undoGroup: true }); - const imported = Array.from(target.documentState.allBlocks()).find( - (block) => block.type === "database", - ); - expect(imported?.props.title).toBe("Roadmap"); - expect(imported?.tableColumns().map((column) => column.id)).toEqual(["name", "tags", "done"]); - expect(imported?.tableRow(0)?.id).toEqual(expect.any(String)); - expect(imported?.tableCell(0, 1)?.textContent()).toBe(JSON.stringify(["feature"])); - expect(imported?.databaseActiveView()).toEqual( - expect.objectContaining({ - title: "Main", - visibleColumnIds: ["name", "tags"], - columnOrder: ["name", "tags", "done"], - }), - ); - - source.destroy(); - target.destroy(); - }); - - it("preserves intentionally empty database rows when round-tripping HTML", async () => { - const source = databaseEditor(); - source.apply([{ - type: "database-insert-row", - blockId: "d1", - rowId: "empty-row", - }]); - const html = await htmlExporter.export(source); - - const blocks = convert(html, defaultRegistry); - const databaseBlock = blocks.find((block) => block.type === "database"); - expect(databaseBlock?.database?.rows).toEqual([ - expect.objectContaining({ - values: { - name: "Ship importer", - tags: JSON.stringify(["feature"]), - done: "false", - }, - }), - { - id: "empty-row", - values: { - name: "", - tags: "", - done: "", - }, - }, - ]); - - const target = createEditor({ - schema: defaultRegistry, - preset: noDefaultExtensionsPreset, - }); - target.apply(blocksToOps(blocks), { origin: "import", undoGroup: true }); - - const imported = Array.from(target.documentState.allBlocks()).find( - (block) => block.type === "database", - ); - expect(imported?.tableRowCount()).toBe(2); - expect(imported?.tableRow(1)?.id).toBe("empty-row"); - expect(imported?.tableCell(1, 0)?.textContent()).toBe(""); - expect(imported?.tableCell(1, 1)?.textContent()).toBe(""); - expect(imported?.tableCell(1, 2)?.textContent()).toBe(""); - - source.destroy(); - target.destroy(); - }); - - it("imports typed HTML tables as database blocks without Pen payload", () => { - const blocks = convert( - '
      NameStatus
      Ship ittodo
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "database", - props: { title: "Untitled", dataSource: "local" }, - database: { - columns: [ - expect.objectContaining({ id: "name", title: "Name", type: "text" }), - expect.objectContaining({ - id: "status", - title: "Status", - type: "select", - options: [{ id: "todo", value: "Todo" }], - }), - ], - rows: [ - expect.objectContaining({ - values: { name: "Ship it", status: "todo" }, - }), - ], - }, - }); - }); - - it("coerces select labels to option IDs during typed HTML import", () => { - const blocks = convert( - '
      NameStatus
      Task ATodo
      Task Bdone
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - const rows = blocks[0].database!.rows; - expect(rows[0].values.status).toBe("todo"); - expect(rows[1].values.status).toBe("done"); - }); - - it("coerces multiSelect labels to option IDs during typed HTML import", () => { - const blocks = convert( - '
      Tags
      Bug, Feature
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - const rows = blocks[0].database!.rows; - expect(rows[0].values.tags).toBe(JSON.stringify(["bug", "feat"])); - }); - - it("preserves hidden and readonly false values during typed HTML import", () => { - const blocks = convert( - '
      A
      x
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - const col = blocks[0].database!.columns[0]; - expect(col.hidden).toBe(false); - expect(col.readonly).toBe(false); - }); - - it("blocksToOps generates correct ops (AC 41)", () => { - const blocks = convert("

      Title

      bold

      "); - const ops = blocksToOps(blocks); - - const insertBlocks = ops.filter((o) => o.type === "insert-block"); - expect(insertBlocks).toHaveLength(2); - - const formatTexts = ops.filter((o) => o.type === "format-text"); - expect(formatTexts.length).toBeGreaterThan(0); - expect(formatTexts[0].marks).toHaveProperty("bold"); - }); - - it("inline-only at block level wraps in paragraph", () => { - const dom = parseHTML("bold at root"); - const blocks = domToBlocks(dom, stubRegistry); - - expect(blocks.some((b) => b.type === "paragraph" && b.content?.includes("bold at root"))).toBe(true); - }); - - it("server-side parsing produces identical blocks as browser-side for same input (AC 43)", () => { - const inputs = [ - "

      Title

      Body

      ", - "
      • a
      • b
      ", - '
      const x = 1;
      ', - "
      ", - 'text', - "

      bold and italic

      ", - ]; - - for (const html of inputs) { - const sanitized = sanitizeHTML(html); - const dom = parseHTML(sanitized); - const blocks = domToBlocks(dom, stubRegistry); - - expect(blocks.length).toBeGreaterThan(0); - for (const block of blocks) { - expect(block.type).toBeTruthy(); - expect(block.props).toBeDefined(); - } - } - }); - - it("
      → toggle block via schema fromHTML", () => { - const blocks = convert( - "
      Toggle title
      ", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - props: { open: false }, - content: "Toggle title", - }); - }); - - it("passes the public HTML import element to schema fromHTML hooks", () => { - let receivedElement: HTMLImportElement | null = null; - const registry: SchemaRegistry = { - ...stubRegistry, - allBlocks: () => [{ - type: "custom", - propSchema: {}, - content: "inline", - serialize: { - fromHTML(element: HTMLImportElement) { - receivedElement = element; - if (element.tagName !== "div") { - return null; - } - return { - type: "paragraph", - props: {}, - content: element.getAttribute("data-title") ?? "", - }; - }, - }, - }], - resolve: (type) => (type === "custom" ? registry.allBlocks()[0] : null), - }; - - const blocks = convert('
      ', registry); - - expect(receivedElement).toMatchObject({ - type: "element", - tagName: "div", - attributes: { "data-title": "From hook" }, - }); - if (!receivedElement) { - throw new Error("Expected schema fromHTML hook to receive an element"); - } - const hookElement = receivedElement as unknown as HTMLImportElement; - expect(hookElement.getAttribute("data-title")).toBe("From hook"); - expect(hookElement.hasAttribute("data-title")).toBe(true); - expect(blocks).toMatchObject([ - { - type: "paragraph", - content: "From hook", - }, - ]); - }); - - it("
      → toggle block with open=true", () => { - const blocks = convert( - '
      Open toggle
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - props: { open: true }, - content: "Open toggle", - }); - }); - - it("preserves inline formatting inside an HTML toggle summary", () => { - const blocks = convert( - "
      Bold and italic
      ", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - content: "Bold and italic", - }); - expect(blocks[0].marks?.some((mark) => mark.type === "bold")).toBe(true); - expect(blocks[0].marks?.some((mark) => mark.type === "italic")).toBe(true); - }); - - it("
      → callout block", () => { - const blocks = convert( - '
      Be careful
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "warning" }, - content: "Be careful", - }); - }); - - it("
      → callout block", () => { - const blocks = convert( - '
      Something failed
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "error" }, - content: "Something failed", - }); - }); - - it("
      → callout block", () => { - const blocks = convert( - '
      FYI
      ', - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "info" }, - content: "FYI", - }); - }); - - it("
        preserves start value on first list item", () => { - const blocks = convert('
        1. fifth
        2. sixth
        '); - - expect(blocks).toHaveLength(2); - expect(blocks[0]).toMatchObject({ - type: "numberedListItem", - content: "fifth", - props: { indent: 0, start: 5 }, - }); - expect(blocks[1]).toMatchObject({ - type: "numberedListItem", - content: "sixth", - props: { indent: 0 }, - }); - }); - - it("
          without start attribute does not set start", () => { - const blocks = convert("
          1. first
          "); - - expect(blocks).toHaveLength(1); - expect(blocks[0].props.start).toBeUndefined(); - }); - - it("keeps parseHtmlToBlocks parse-only in flow documents", () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const blocks = parseHtmlToBlocks(`${html}

          Allowed

          `, editor); - - expect(blocks.some((block) => block.type === "database")).toBe(true); - expect(blocks.some((block) => block.type === "heading")).toBe(true); - - source.destroy(); - editor.destroy(); - }); - - it("does not emit normalization diagnostics during parseHtmlToBlocks", () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - parseHtmlToBlocks(`${html}

          Allowed

          `, editor); - - expect(diagnostics).toEqual([]); - - source.destroy(); - editor.destroy(); - }); - - it("filters flow-disallowed blocks during direct HTML import into flow documents", async () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - await htmlImporter.import(`${html}

          Allowed

          `, editor); - - const blockOrder = editor.documentState.blockOrder; - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), - ).toBe(true); - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), - ).toBe(false); - - source.destroy(); - editor.destroy(); - }); - - it("returns a structured import result for HTML imports with normalization", async () => { - const source = databaseEditor(); - const html = htmlExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const result = await htmlImporter.import(`${html}

          Allowed

          `, editor); - - expect(result).toEqual({ - parsedTopLevelBlockCount: 3, - importedTopLevelBlockCount: 2, - droppedBlockCount: 1, - droppedBlockTypes: ["database"], - normalized: true, - }); - - source.destroy(); - editor.destroy(); - }); -}); diff --git a/packages/extensions/import-html/src/domToBlocks.ts b/packages/extensions/import-html/src/domToBlocks.ts index 1f7f854..6d81958 100644 --- a/packages/extensions/import-html/src/domToBlocks.ts +++ b/packages/extensions/import-html/src/domToBlocks.ts @@ -2,14 +2,16 @@ import type { DOMNode } from "./domAdapter"; import { parseInlineContent } from "./inlineParser"; import type { BlockImportMatch, - DatabaseViewState, HTMLImportElement, HTMLImportNode, SchemaRegistry, - TableColumnSchema, } from "@pen/types"; import type { PendingBlock } from "@pen/core"; -import { normalizeStoredSelectValue } from "@pen/types"; +import { + collectTableRows, + parseDatabasePayload, + parseTypedDatabaseTable, +} from "./domToDatabaseBlocks"; const BLOCK_ELEMENT_MAP: Record PendingBlock> = { h1: (node) => blockWithInline("heading", { level: 1 }, node), @@ -261,215 +263,6 @@ function parseHTMLTable(node: DOMNode): PendingBlock { }; } -function parseTypedDatabaseTable(node: DOMNode): PendingBlock | null { - const headerCells = collectDatabaseHeaderCells(node); - if (headerCells.length === 0) { - return null; - } - - const columns = headerCells.map((cell, index) => { - const columnId = cell.attributes?.["data-col-id"]?.trim() || `col-${index}`; - const columnType = cell.attributes?.["data-col-type"]?.trim() || "text"; - const inline = parseInlineContent(cell); - const options = parseEncodedJSON(cell.attributes?.["data-col-options"]); - const format = parseEncodedJSON(cell.attributes?.["data-col-format"]); - const width = cell.attributes?.["data-col-width"]; - const pinned = cell.attributes?.["data-col-pinned"]; - const column: TableColumnSchema = { - id: columnId, - title: inline.text || `Column ${index + 1}`, - type: columnType as TableColumnSchema["type"], - }; - - if (Array.isArray(options)) { - column.options = options as TableColumnSchema["options"]; - } - if (format && typeof format === "object") { - column.format = format as TableColumnSchema["format"]; - } - if (width != null && width !== "" && Number.isFinite(Number(width))) { - column.width = Number(width); - } - if (pinned === "left" || pinned === "right") { - column.pinned = pinned; - } - if (cell.attributes?.["data-col-hidden"] !== undefined) { - column.hidden = cell.attributes["data-col-hidden"] === "true"; - } - if (cell.attributes?.["data-col-readonly"] !== undefined) { - column.readonly = cell.attributes["data-col-readonly"] === "true"; - } - - return column; - }); - - const bodyRows = collectDatabaseBodyRows(node); - const rows = bodyRows.map((row, rowIndex) => { - const cellNodes = (row.children ?? []).filter((child) => child.tagName === "td" || child.tagName === "th"); - const values = Object.fromEntries( - columns.map((column, columnIndex) => { - const raw = parseInlineContent(cellNodes[columnIndex] ?? { type: "element", tagName: "span", children: [] }).text; - return [column.id, coerceImportedCellValue(raw, column)]; - }), - ); - - return { - id: `row-${rowIndex}`, - values, - }; - }); - - return { - type: "database", - props: { - title: "Untitled", - dataSource: "local", - }, - database: { - columns, - rows, - views: undefined, - primaryViewId: null, - }, - }; -} - -function coerceImportedCellValue(raw: string, column: TableColumnSchema): string { - if (!raw || !column.options?.length) { - return raw; - } - if (column.type === "select") { - return normalizeStoredSelectValue(raw, column.options); - } - if (column.type === "multiSelect") { - let parsed: string[]; - try { - const json = JSON.parse(raw); - parsed = Array.isArray(json) ? json.map(String) : [raw]; - } catch { - parsed = raw.split(",").map((s) => s.trim()).filter(Boolean); - } - const normalized = parsed.map((v) => normalizeStoredSelectValue(v, column.options)); - return normalized.length > 0 ? JSON.stringify(normalized) : raw; - } - return raw; -} - -function parseDatabasePayload( - rawValue: string | undefined, -): { - title?: string; - dataSource?: string; - columns: TableColumnSchema[]; - rows: Array<{ id: string; values: Record }>; - views?: DatabaseViewState[]; - primaryViewId?: string | null; -} | null { - if (!rawValue) { - return null; - } - try { - const parsed = JSON.parse(decodeURIComponent(rawValue)) as { - title?: string; - dataSource?: string; - columns?: TableColumnSchema[]; - rows?: Array<{ id?: string; values?: Record }>; - views?: DatabaseViewState[]; - primaryViewId?: string | null; - }; - if (!Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) { - return null; - } - return { - title: parsed.title, - dataSource: parsed.dataSource, - columns: parsed.columns, - rows: parsed.rows.map((row, index) => ({ - id: - typeof row?.id === "string" && row.id.length > 0 - ? row.id - : `row-${index}`, - values: Object.fromEntries( - Object.entries(row?.values ?? {}).map(([key, value]) => [ - key, - value == null ? "" : String(value), - ]), - ), - })), - views: Array.isArray(parsed.views) ? parsed.views : undefined, - primaryViewId: - typeof parsed.primaryViewId === "string" || parsed.primaryViewId === null - ? parsed.primaryViewId - : undefined, - }; - } catch { - return null; - } -} - -function collectTableRows(tableNode: DOMNode): DOMNode[] { - const rows: DOMNode[] = []; - for (const child of tableNode.children ?? []) { - if (child.tagName === "tr") { - rows.push(child); - } else if ( - child.tagName === "thead" || - child.tagName === "tbody" || - child.tagName === "tfoot" - ) { - for (const row of child.children ?? []) { - if (row.tagName === "tr") rows.push(row); - } - } - } - return rows; -} - -function collectDatabaseHeaderCells(tableNode: DOMNode): DOMNode[] { - const headerRow = - tableNode.children?.find((child) => child.tagName === "thead")?.children?.find( - (child) => child.tagName === "tr", - ) ?? - collectTableRows(tableNode).find((row) => - (row.children ?? []).some((child) => child.tagName === "th" && child.attributes?.["data-col-type"] != null), - ) ?? - null; - - if (!headerRow) { - return []; - } - - const headerCells = (headerRow.children ?? []).filter((child) => child.tagName === "th"); - const isTyped = headerCells.some( - (cell) => - cell.attributes?.["data-col-type"] != null || - cell.attributes?.["data-col-id"] != null, - ); - return isTyped ? headerCells : []; -} - -function collectDatabaseBodyRows(tableNode: DOMNode): DOMNode[] { - const tbody = tableNode.children?.find((child) => child.tagName === "tbody"); - if (tbody) { - return (tbody.children ?? []).filter((child) => child.tagName === "tr"); - } - - const allRows = collectTableRows(tableNode); - return allRows.slice(1); -} - -function parseEncodedJSON(rawValue: string | undefined): unknown { - if (!rawValue) { - return undefined; - } - - try { - return JSON.parse(decodeURIComponent(rawValue)); - } catch { - return undefined; - } -} - function blockWithInline( type: string, props: Record, diff --git a/packages/extensions/import-html/src/domToDatabaseBlocks.ts b/packages/extensions/import-html/src/domToDatabaseBlocks.ts new file mode 100644 index 0000000..2a91196 --- /dev/null +++ b/packages/extensions/import-html/src/domToDatabaseBlocks.ts @@ -0,0 +1,219 @@ +import type { DOMNode } from "./domAdapter"; +import { parseInlineContent } from "./inlineParser"; +import type { DatabaseViewState, TableColumnSchema } from "@pen/types"; +import type { PendingBlock } from "@pen/core"; +import { normalizeStoredSelectValue } from "@pen/types"; + +export function parseTypedDatabaseTable(node: DOMNode): PendingBlock | null { + const headerCells = collectDatabaseHeaderCells(node); + if (headerCells.length === 0) { + return null; + } + + const columns = headerCells.map((cell, index) => { + const columnId = cell.attributes?.["data-col-id"]?.trim() || `col-${index}`; + const columnType = cell.attributes?.["data-col-type"]?.trim() || "text"; + const inline = parseInlineContent(cell); + const options = parseEncodedJSON(cell.attributes?.["data-col-options"]); + const format = parseEncodedJSON(cell.attributes?.["data-col-format"]); + const width = cell.attributes?.["data-col-width"]; + const pinned = cell.attributes?.["data-col-pinned"]; + const column: TableColumnSchema = { + id: columnId, + title: inline.text || `Column ${index + 1}`, + type: columnType as TableColumnSchema["type"], + }; + + if (Array.isArray(options)) { + column.options = options as TableColumnSchema["options"]; + } + if (format && typeof format === "object") { + column.format = format as TableColumnSchema["format"]; + } + if (width != null && width !== "" && Number.isFinite(Number(width))) { + column.width = Number(width); + } + if (pinned === "left" || pinned === "right") { + column.pinned = pinned; + } + if (cell.attributes?.["data-col-hidden"] !== undefined) { + column.hidden = cell.attributes["data-col-hidden"] === "true"; + } + if (cell.attributes?.["data-col-readonly"] !== undefined) { + column.readonly = cell.attributes["data-col-readonly"] === "true"; + } + + return column; + }); + + const bodyRows = collectDatabaseBodyRows(node); + const rows = bodyRows.map((row, rowIndex) => { + const cellNodes = (row.children ?? []).filter((child) => child.tagName === "td" || child.tagName === "th"); + const values = Object.fromEntries( + columns.map((column, columnIndex) => { + const raw = parseInlineContent(cellNodes[columnIndex] ?? { type: "element", tagName: "span", children: [] }).text; + return [column.id, coerceImportedCellValue(raw, column)]; + }), + ); + + return { + id: `row-${rowIndex}`, + values, + }; + }); + + return { + type: "database", + props: { + title: "Untitled", + dataSource: "local", + }, + database: { + columns, + rows, + views: undefined, + primaryViewId: null, + }, + }; +} + +function coerceImportedCellValue(raw: string, column: TableColumnSchema): string { + if (!raw || !column.options?.length) { + return raw; + } + if (column.type === "select") { + return normalizeStoredSelectValue(raw, column.options); + } + if (column.type === "multiSelect") { + let parsed: string[]; + try { + const json = JSON.parse(raw); + parsed = Array.isArray(json) ? json.map(String) : [raw]; + } catch { + parsed = raw.split(",").map((s) => s.trim()).filter(Boolean); + } + const normalized = parsed.map((v) => normalizeStoredSelectValue(v, column.options)); + return normalized.length > 0 ? JSON.stringify(normalized) : raw; + } + return raw; +} + +export function parseDatabasePayload( + rawValue: string | undefined, +): { + title?: string; + dataSource?: string; + columns: TableColumnSchema[]; + rows: Array<{ id: string; values: Record }>; + views?: DatabaseViewState[]; + primaryViewId?: string | null; +} | null { + if (!rawValue) { + return null; + } + try { + const parsed = JSON.parse(decodeURIComponent(rawValue)) as { + title?: string; + dataSource?: string; + columns?: TableColumnSchema[]; + rows?: Array<{ id?: string; values?: Record }>; + views?: DatabaseViewState[]; + primaryViewId?: string | null; + }; + if (!Array.isArray(parsed.columns) || !Array.isArray(parsed.rows)) { + return null; + } + return { + title: parsed.title, + dataSource: parsed.dataSource, + columns: parsed.columns, + rows: parsed.rows.map((row, index) => ({ + id: + typeof row?.id === "string" && row.id.length > 0 + ? row.id + : `row-${index}`, + values: Object.fromEntries( + Object.entries(row?.values ?? {}).map(([key, value]) => [ + key, + value == null ? "" : String(value), + ]), + ), + })), + views: Array.isArray(parsed.views) ? parsed.views : undefined, + primaryViewId: + typeof parsed.primaryViewId === "string" || parsed.primaryViewId === null + ? parsed.primaryViewId + : undefined, + }; + } catch { + return null; + } +} + +export function collectTableRows(tableNode: DOMNode): DOMNode[] { + const rows: DOMNode[] = []; + for (const child of tableNode.children ?? []) { + if (child.tagName === "tr") { + rows.push(child); + } else if ( + child.tagName === "thead" || + child.tagName === "tbody" || + child.tagName === "tfoot" + ) { + for (const row of child.children ?? []) { + if (row.tagName === "tr") rows.push(row); + } + } + } + return rows; +} + +function collectDatabaseHeaderCells(tableNode: DOMNode): DOMNode[] { + const headerRow = + tableNode.children?.find((child) => child.tagName === "thead")?.children?.find( + (child) => child.tagName === "tr", + ) ?? + collectTableRows(tableNode).find((row) => + (row.children ?? []).some((child) => child.tagName === "th" && child.attributes?.["data-col-type"] != null), + ) ?? + null; + + if (!headerRow) { + return []; + } + + const headerCells = (headerRow.children ?? []).filter((child) => child.tagName === "th"); + const isTyped = headerCells.some( + (cell) => + cell.attributes?.["data-col-type"] != null || + cell.attributes?.["data-col-id"] != null, + ); + return isTyped ? headerCells : []; +} + +function collectDatabaseBodyRows(tableNode: DOMNode): DOMNode[] { + const tbody = tableNode.children?.find((child) => child.tagName === "tbody"); + if (tbody) { + return (tbody.children ?? []).filter((child) => child.tagName === "tr"); + } + + const allRows = collectTableRows(tableNode); + return allRows.slice(1); +} + +function parseEncodedJSON(rawValue: string | undefined): unknown { + if (!rawValue) { + return undefined; + } + + try { + return JSON.parse(decodeURIComponent(rawValue)); + } catch { + return undefined; + } +} + +function extractText(node: DOMNode): string { + if (node.type === "text") return node.textContent ?? ""; + return (node.children ?? []).map(extractText).join(""); +} diff --git a/packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts b/packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts new file mode 100644 index 0000000..4617b43 --- /dev/null +++ b/packages/extensions/import-markdown/src/__tests__/importMarkdown.part2.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import { blocksToOps, createEditor } from "@pen/core"; +import type { SchemaRegistry } from "@pen/types"; +import { markdownExporter } from "@pen/export-markdown"; +import { createDefaultSchema } from "@pen/schema-default"; +import { markdownImporter, parseMarkdownToBlocks } from "../importer"; + +const noDefaultExtensionsPreset = { + resolve() { + return { extensions: [] }; + }, +}; + +const stubRegistry: SchemaRegistry = { + resolve: () => null, + resolveInline: () => null, + resolveApp: () => null, + resolveLayout: () => null, + allBlocks: () => [], + allInlines: () => [], + allApps: () => [], + allBlockDisplays: () => [], +}; + +const defaultRegistry = createDefaultSchema(); + +function convert(md: string, registry: SchemaRegistry = stubRegistry) { + return parseMarkdownToBlocks(md, { + schema: registry, + } as never); +} + +function databaseEditor() { + const editor = createEditor({ + schema: defaultRegistry, + preset: noDefaultExtensionsPreset, + }); + editor.apply([{ + type: "insert-block", + blockId: "d1", + blockType: "database", + props: { title: "Roadmap", dataSource: "local" }, + position: "last", + }]); + editor.apply([{ + type: "update-table-columns", + blockId: "d1", + columns: [ + { id: "name", title: "Name", type: "text" }, + { + id: "tags", + title: "Tags", + type: "multiSelect", + options: [ + { id: "bug", value: "Bug", color: "red" }, + { id: "feature", value: "Feature", color: "blue" }, + ], + }, + { id: "done", title: "Done", type: "checkbox" }, + ], + }]); + editor.apply([{ + type: "database-insert-row", + blockId: "d1", + rowId: "roadmap-1", + values: { + name: "Ship importer", + tags: JSON.stringify(["Feature"]), + done: "false", + }, + }]); + editor.apply([{ + type: "database-update-view", + blockId: "d1", + patch: { + title: "Main", + type: "table", + visibleColumnIds: ["name", "tags"], + columnOrder: ["name", "tags", "done"], + sort: [{ columnId: "name", direction: "asc" }], + }, + }]); + return editor; +} + +describe("@pen/import-markdown", () => { + it("preserves inline formatting after a markdown callout prefix", () => { + const blocks = convert( + "> **Note:** This is *very* [important](https://example.com)", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "callout", + props: { type: "info" }, + content: "This is very important", + }); + + const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); + expect(italicMark).toMatchObject({ start: 8, end: 12 }); + + const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); + expect(linkMark).toMatchObject({ + start: 13, + end: 22, + props: { href: "https://example.com" }, + }); + }); + + it("preserves inline formatting inside a toggle summary HTML block", () => { + const blocks = convert( + "
          Very important
          ", + defaultRegistry, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ + type: "toggle", + props: { open: false }, + content: "Very important", + }); + + const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); + expect(italicMark).toMatchObject({ start: 0, end: 4 }); + + const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); + expect(linkMark).toMatchObject({ + start: 5, + end: 14, + props: { href: "https://example.com" }, + }); + }); + + it("plain blockquote stays blockquote (not callout)", () => { + const blocks = convert("> Just a regular quote", defaultRegistry); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("blockquote"); + }); + + it("keeps parseMarkdownToBlocks parse-only in flow documents", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const blocks = parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); + + expect(blocks.map((block) => block.type)).toEqual(["database", "heading"]); + + source.destroy(); + editor.destroy(); + }); + + it("does not emit normalization diagnostics during parseMarkdownToBlocks", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + const diagnostics: unknown[] = []; + + editor.on("diagnostic", (event) => { + diagnostics.push(event); + }); + + parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); + + expect(diagnostics).toEqual([]); + + source.destroy(); + editor.destroy(); + }); + + it("filters flow-disallowed blocks during direct markdown import into flow documents", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + markdownImporter.import(`${markdown}\n\n## Allowed`, editor); + + const blockOrder = editor.documentState.blockOrder; + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), + ).toBe(true); + expect( + blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), + ).toBe(false); + + source.destroy(); + editor.destroy(); + }); + + it("returns a structured import result for markdown imports with normalization", () => { + const source = databaseEditor(); + const markdown = markdownExporter.export(source); + const editor = createEditor({ + schema: defaultRegistry, + documentProfile: "flow", + preset: noDefaultExtensionsPreset, + }); + + const result = markdownImporter.import(`${markdown}\n\n## Allowed`, editor); + + expect(result).toEqual({ + parsedTopLevelBlockCount: 2, + importedTopLevelBlockCount: 1, + droppedBlockCount: 1, + droppedBlockTypes: ["database"], + normalized: true, + }); + + source.destroy(); + editor.destroy(); + }); +}); diff --git a/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts b/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts index 967de06..7457ded 100644 --- a/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts +++ b/packages/extensions/import-markdown/src/__tests__/importMarkdown.test.ts @@ -426,143 +426,4 @@ describe("@pen/import-markdown", () => { }); }); - it("preserves inline formatting after a markdown callout prefix", () => { - const blocks = convert( - "> **Note:** This is *very* [important](https://example.com)", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "callout", - props: { type: "info" }, - content: "This is very important", - }); - - const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); - expect(italicMark).toMatchObject({ start: 8, end: 12 }); - - const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); - expect(linkMark).toMatchObject({ - start: 13, - end: 22, - props: { href: "https://example.com" }, - }); - }); - - it("preserves inline formatting inside a toggle summary HTML block", () => { - const blocks = convert( - "
          Very important
          ", - defaultRegistry, - ); - - expect(blocks).toHaveLength(1); - expect(blocks[0]).toMatchObject({ - type: "toggle", - props: { open: false }, - content: "Very important", - }); - - const italicMark = blocks[0].marks?.find((mark) => mark.type === "italic"); - expect(italicMark).toMatchObject({ start: 0, end: 4 }); - - const linkMark = blocks[0].marks?.find((mark) => mark.type === "link"); - expect(linkMark).toMatchObject({ - start: 5, - end: 14, - props: { href: "https://example.com" }, - }); - }); - - it("plain blockquote stays blockquote (not callout)", () => { - const blocks = convert("> Just a regular quote", defaultRegistry); - - expect(blocks).toHaveLength(1); - expect(blocks[0].type).toBe("blockquote"); - }); - - it("keeps parseMarkdownToBlocks parse-only in flow documents", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const blocks = parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); - - expect(blocks.map((block) => block.type)).toEqual(["database", "heading"]); - - source.destroy(); - editor.destroy(); - }); - - it("does not emit normalization diagnostics during parseMarkdownToBlocks", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - const diagnostics: unknown[] = []; - - editor.on("diagnostic", (event) => { - diagnostics.push(event); - }); - - parseMarkdownToBlocks(`${markdown}\n\n## Allowed`, editor); - - expect(diagnostics).toEqual([]); - - source.destroy(); - editor.destroy(); - }); - - it("filters flow-disallowed blocks during direct markdown import into flow documents", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - markdownImporter.import(`${markdown}\n\n## Allowed`, editor); - - const blockOrder = editor.documentState.blockOrder; - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "heading"), - ).toBe(true); - expect( - blockOrder.some((blockId) => editor.getBlock(blockId)?.type === "database"), - ).toBe(false); - - source.destroy(); - editor.destroy(); - }); - - it("returns a structured import result for markdown imports with normalization", () => { - const source = databaseEditor(); - const markdown = markdownExporter.export(source); - const editor = createEditor({ - schema: defaultRegistry, - documentProfile: "flow", - preset: noDefaultExtensionsPreset, - }); - - const result = markdownImporter.import(`${markdown}\n\n## Allowed`, editor); - - expect(result).toEqual({ - parsedTopLevelBlockCount: 2, - importedTopLevelBlockCount: 1, - droppedBlockCount: 1, - droppedBlockTypes: ["database"], - normalized: true, - }); - - source.destroy(); - editor.destroy(); - }); }); diff --git a/packages/extensions/undo/src/undoExtension.ts b/packages/extensions/undo/src/undoExtension.ts index 8397a5f..c7b119d 100644 --- a/packages/extensions/undo/src/undoExtension.ts +++ b/packages/extensions/undo/src/undoExtension.ts @@ -13,6 +13,7 @@ import type { import { defineExtension, FIELD_EDITOR_SLOT_KEY, + getOpOriginType, UNDO_HISTORY_METADATA_CONTROLLER_SLOT_KEY, UNDO_HISTORY_RESTORE_SLOT_KEY, } from "@pen/types"; @@ -102,7 +103,7 @@ export function undoExtension(options?: UndoExtensionOptions): Extension { const { adapter, crdtDoc } = ctx.editor.internals; const crdtUndo = adapter.createUndoManager(crdtDoc, { - trackedOrigins: [...trackedOrigins], + trackedOriginTypes: [...trackedOrigins].map(getOpOriginType), captureTimeout: options?.groupTimeout ?? 400, }); diff --git a/packages/extensions/undo/src/undoManager.ts b/packages/extensions/undo/src/undoManager.ts index 6d49d00..9b817c0 100644 --- a/packages/extensions/undo/src/undoManager.ts +++ b/packages/extensions/undo/src/undoManager.ts @@ -4,12 +4,13 @@ import type { OpOrigin, Unsubscribe, } from "@pen/types"; +import { getOpOriginType } from "@pen/types"; const EXPLICIT_GROUP_CAPTURE_TIMEOUT_MS = 2_147_483_647; export class UndoManagerImpl implements UndoManager { private readonly _crdtUndo: CRDTUndoManager; - private readonly _trackedOrigins = new Map(); + private readonly _trackedOriginTypes = new Map(); private readonly _listeners = new Set<() => void>(); private _idleTimer: ReturnType | null = null; private _groupTimeout = 1000; @@ -21,7 +22,7 @@ export class UndoManagerImpl implements UndoManager { constructor(crdtUndo: CRDTUndoManager, trackedOrigins?: Iterable) { this._crdtUndo = crdtUndo; for (const origin of trackedOrigins ?? []) { - this._trackedOrigins.set(origin, 1); + this._trackedOriginTypes.set(getOpOriginType(origin), 1); } } @@ -119,7 +120,7 @@ export class UndoManagerImpl implements UndoManager { } hasTrackedOrigin(origin: OpOrigin): boolean { - return (this._trackedOrigins.get(origin) ?? 0) > 0; + return (this._trackedOriginTypes.get(getOpOriginType(origin)) ?? 0) > 0; } onStackChange(callback: () => void): Unsubscribe { @@ -158,21 +159,23 @@ export class UndoManagerImpl implements UndoManager { } private _incrementTrackedOrigin(origin: OpOrigin): void { - const count = this._trackedOrigins.get(origin) ?? 0; + const originType = getOpOriginType(origin); + const count = this._trackedOriginTypes.get(originType) ?? 0; if (count === 0) { - this._crdtUndo.addTrackedOrigin(origin); + this._crdtUndo.addTrackedOrigin(originType); } - this._trackedOrigins.set(origin, count + 1); + this._trackedOriginTypes.set(originType, count + 1); } private _decrementTrackedOrigin(origin: OpOrigin): void { - const count = this._trackedOrigins.get(origin) ?? 0; + const originType = getOpOriginType(origin); + const count = this._trackedOriginTypes.get(originType) ?? 0; if (count <= 1) { - this._trackedOrigins.delete(origin); - this._crdtUndo.removeTrackedOrigin(origin); + this._trackedOriginTypes.delete(originType); + this._crdtUndo.removeTrackedOrigin(originType); return; } - this._trackedOrigins.set(origin, count - 1); + this._trackedOriginTypes.set(originType, count - 1); } private _clearIdleTimer(): void { diff --git a/packages/rendering/dom/src/__tests__/inlineDecorations.test.ts b/packages/rendering/dom/src/__tests__/inlineDecorations.test.ts new file mode 100644 index 0000000..5e19917 --- /dev/null +++ b/packages/rendering/dom/src/__tests__/inlineDecorations.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { DECORATION_OMIT_FROM_RENDER_ATTRIBUTE } from "@pen/types"; +import type { InlineDecoration } from "@pen/types"; +import { + applyInlineDecorationsToDeltas, + buildInlineDecorationsRenderSignature, + filterVisibleInlineDecorationDeltas, + inlineDecorationsRequireFullReconcile, +} from "../utils/inlineDecorations"; + +describe("inline decorations", () => { + it("renders virtual inline decoration text without keeping hidden source text", () => { + const decorations = [ + { + type: "inline", + blockId: "body-1", + from: 0, + to: 5, + omitFromRender: true, + attributes: {}, + }, + { + type: "inline", + blockId: "body-1", + from: 5, + to: 5, + virtualText: "Hi", + virtualPlacement: "after", + attributes: { + "data-pen-ai-review-preview-virtual": true, + }, + }, + ] as InlineDecoration[]; + const deltas = applyInlineDecorationsToDeltas( + [{ insert: "Hello world" }], + decorations, + ); + + expect(filterVisibleInlineDecorationDeltas(deltas)).toEqual([ + { + insert: "Hi", + attributes: { + __penInlineDecoration: { + "data-pen-ai-review-preview-virtual": true, + "data-pen-virtual-inline": true, + }, + }, + }, + { insert: " world" }, + ]); + }); + + it("requires full reconcile when virtual or hidden inline decorations are present", () => { + expect( + inlineDecorationsRequireFullReconcile([ + { + type: "inline", + blockId: "body-1", + from: 5, + to: 5, + virtualText: "Hi", + virtualPlacement: "after", + attributes: {}, + } as InlineDecoration, + ]), + ).toBe(true); + expect( + inlineDecorationsRequireFullReconcile([ + { + type: "inline", + blockId: "body-1", + from: 0, + to: 5, + omitFromRender: true, + attributes: {}, + } as InlineDecoration, + ]), + ).toBe(true); + expect( + inlineDecorationsRequireFullReconcile([ + { + type: "inline", + blockId: "body-1", + from: 0, + to: 2, + attributes: { bold: true }, + } as InlineDecoration, + ]), + ).toBe(false); + }); + + it("includes omitFromRender in inline decoration render signatures", () => { + const visibleDecoration = { + type: "inline", + blockId: "body-1", + from: 0, + to: 5, + attributes: {}, + } as InlineDecoration; + const hiddenDecoration = { + ...visibleDecoration, + omitFromRender: true, + } as InlineDecoration; + + expect( + buildInlineDecorationsRenderSignature([visibleDecoration]), + ).not.toBe( + buildInlineDecorationsRenderSignature([hiddenDecoration]), + ); + }); +}); diff --git a/packages/rendering/dom/src/__tests__/publicApi.test.ts b/packages/rendering/dom/src/__tests__/publicApi.test.ts index e7734ea..f5ed62e 100644 --- a/packages/rendering/dom/src/__tests__/publicApi.test.ts +++ b/packages/rendering/dom/src/__tests__/publicApi.test.ts @@ -1,7 +1,15 @@ -import { describe, expect, it } from "vitest"; +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_SELECT_ALL_BEHAVIOR, + handleEditorDocumentKeyDown, + isActiveFieldEditorTextEntryTarget, + isFieldEditorTextEditingKey, + isFieldEditorTextEntryTarget, + isNativeTextEntryTarget, resolveSelectAllBehavior, + shouldHandleEditorKeyboardEvent, } from "../index"; import { DATA_ATTRS, @@ -13,7 +21,9 @@ describe("@pen/dom public helpers", () => { it("resolves select-all behavior from the interaction model", () => { expect(DEFAULT_SELECT_ALL_BEHAVIOR).toBe("document-first"); expect(resolveSelectAllBehavior("block-first")).toBe("block-first"); - expect(resolveSelectAllBehavior("content-first")).toBe("document-first"); + expect(resolveSelectAllBehavior("content-first")).toBe( + "document-first", + ); }); it("builds DOM data attributes predictably", () => { @@ -33,4 +43,228 @@ describe("@pen/dom public helpers", () => { "data-index": "2", }); }); + + it("classifies native and field-editor text entry targets", () => { + const input = document.createElement("input"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + const textbox = document.createElement("div"); + textbox.setAttribute("role", "textbox"); + const fieldSurface = document.createElement("div"); + fieldSurface.setAttribute(DATA_ATTRS.fieldEditorSurface, ""); + const activeFieldSurface = document.createElement("div"); + activeFieldSurface.setAttribute( + DATA_ATTRS.fieldEditorActiveSurface, + "", + ); + + expect(isNativeTextEntryTarget(input)).toBe(true); + expect(isNativeTextEntryTarget(checkbox)).toBe(false); + expect(isNativeTextEntryTarget(textbox)).toBe(true); + expect(isFieldEditorTextEntryTarget(fieldSurface)).toBe(true); + expect(isActiveFieldEditorTextEntryTarget(activeFieldSurface)).toBe( + true, + ); + }); + + it("rejects modified or composing field-editor editing keys", () => { + expect( + isFieldEditorTextEditingKey( + new KeyboardEvent("keydown", { key: "a" }), + ), + ).toBe(true); + expect( + isFieldEditorTextEditingKey( + new KeyboardEvent("keydown", { key: "a", metaKey: true }), + ), + ).toBe(false); + expect( + isFieldEditorTextEditingKey( + new KeyboardEvent("keydown", { + key: "Backspace", + isComposing: true, + }), + ), + ).toBe(false); + }); + + it("routes document keyboard handling through the shared text-entry model", () => { + const root = document.createElement("div"); + root.setAttribute(DATA_ATTRS.editorRoot, ""); + const activeFieldSurface = document.createElement("div"); + activeFieldSurface.setAttribute(DATA_ATTRS.fieldEditorSurface, ""); + activeFieldSurface.setAttribute( + DATA_ATTRS.fieldEditorActiveSurface, + "", + ); + root.append(activeFieldSurface); + document.body.append(root); + + let shouldHandleCollapsedText = true; + activeFieldSurface.addEventListener( + "keydown", + (event) => { + shouldHandleCollapsedText = shouldHandleEditorKeyboardEvent({ + root, + event, + selection: { + type: "text", + isCollapsed: true, + isMultiBlock: false, + }, + }); + }, + { once: true }, + ); + activeFieldSurface.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + expect(shouldHandleCollapsedText).toBe(false); + + let shouldHandleMultiBlockText = false; + activeFieldSurface.addEventListener( + "keydown", + (event) => { + shouldHandleMultiBlockText = shouldHandleEditorKeyboardEvent({ + root, + event, + selection: { + type: "text", + isCollapsed: false, + isMultiBlock: true, + }, + }); + }, + { once: true }, + ); + activeFieldSurface.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + expect(shouldHandleMultiBlockText).toBe(true); + + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { key: "Backspace" }), + selection: { + type: "block", + blockIds: ["block-1"], + }, + }), + ).toBe(true); + + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { + key: "z", + metaKey: true, + }), + selection: { + type: "text", + isCollapsed: true, + isMultiBlock: false, + }, + }), + ).toBe(true); + + const externalInput = document.createElement("input"); + document.body.append(externalInput); + externalInput.focus(); + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { + key: "z", + metaKey: true, + }), + selection: { + type: "text", + isCollapsed: true, + isMultiBlock: false, + }, + }), + ).toBe(false); + externalInput.remove(); + + root.remove(); + }); + + it("keeps keyboard routing scoped to the active editor root", () => { + const root = document.createElement("div"); + root.setAttribute(DATA_ATTRS.editorRoot, ""); + const otherRoot = document.createElement("div"); + otherRoot.setAttribute(DATA_ATTRS.editorRoot, ""); + const otherFieldSurface = document.createElement("div"); + otherFieldSurface.setAttribute(DATA_ATTRS.fieldEditorSurface, ""); + otherRoot.append(otherFieldSurface); + document.body.append(root, otherRoot); + + expect( + shouldHandleEditorKeyboardEvent({ + root, + event: new KeyboardEvent("keydown", { + key: "Backspace", + }), + selection: null, + hasMappedDomSelection: () => true, + }), + ).toBe(true); + + let shouldHandleOtherRootEvent = true; + otherFieldSurface.addEventListener( + "keydown", + (event) => { + shouldHandleOtherRootEvent = shouldHandleEditorKeyboardEvent({ + root, + event, + selection: { + type: "block", + blockIds: ["block-1"], + }, + }); + }, + { once: true }, + ); + otherFieldSurface.dispatchEvent( + new KeyboardEvent("keydown", { key: "Backspace", bubbles: true }), + ); + expect(shouldHandleOtherRootEvent).toBe(false); + + root.remove(); + otherRoot.remove(); + }); + + it("deletes document selections through the shared keydown handler with user origin", () => { + const root = document.createElement("div"); + const deleteSelection = vi.fn(); + const deactivate = vi.fn(); + const editor = { + selection: { + type: "block", + blockIds: ["block-1"], + }, + deleteSelection, + firstBlock: () => null, + internals: { + getSlot: () => undefined, + }, + }; + const fieldEditor = { + deactivate, + isComposing: false, + isEditing: false, + }; + + const handled = handleEditorDocumentKeyDown({ + event: new KeyboardEvent("keydown", { key: "Backspace" }), + editor: editor as never, + fieldEditor: fieldEditor as never, + root, + }); + + expect(handled).toBe(true); + expect(deleteSelection).toHaveBeenCalledWith({ origin: "user" }); + expect(deactivate).toHaveBeenCalled(); + }); }); diff --git a/packages/rendering/dom/src/field-editor/backendLifecycleController.ts b/packages/rendering/dom/src/field-editor/backendLifecycleController.ts new file mode 100644 index 0000000..2bbaea3 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/backendLifecycleController.ts @@ -0,0 +1,58 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import type { InputBackend } from "../internal/inputBackend"; + +export type InputBackendConstructor = new ( + editor: Editor, + fieldEditor: FieldEditorInputController, +) => InputBackend; + +export class BackendLifecycleController { + private readonly editor: Editor; + private readonly fieldEditor: FieldEditorInputController; + private backend: InputBackend | null = null; + + constructor(editor: Editor, fieldEditor: FieldEditorInputController) { + this.editor = editor; + this.fieldEditor = fieldEditor; + } + + get current(): InputBackend | null { + return this.backend; + } + + hasBackend(BackendClass: InputBackendConstructor): boolean { + return this.backend?.constructor === BackendClass; + } + + create(BackendClass: InputBackendConstructor): InputBackend { + return new BackendClass(this.editor, this.fieldEditor); + } + + replace(BackendClass: InputBackendConstructor): InputBackend { + this.deactivate(); + this.backend = this.create(BackendClass); + return this.backend; + } + + ensure(BackendClass: InputBackendConstructor): InputBackend { + if (this.backend?.constructor === BackendClass) { + return this.backend; + } + return this.replace(BackendClass); + } + + activate(element: HTMLElement, ytext: FieldEditorTextLike): void { + this.backend?.activate(element, ytext); + } + + updateSelection(relPos: unknown): void { + this.backend?.updateSelection(relPos); + } + + deactivate(): void { + this.backend?.deactivate(); + this.backend = null; + } +} diff --git a/packages/rendering/dom/src/field-editor/cellEditingController.ts b/packages/rendering/dom/src/field-editor/cellEditingController.ts new file mode 100644 index 0000000..f9d1c7e --- /dev/null +++ b/packages/rendering/dom/src/field-editor/cellEditingController.ts @@ -0,0 +1,124 @@ +import type { + ActiveCellCoord, + FieldEditorFocusReason, + PenFieldEditorFocusOptions, +} from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import { resolveCellInlineElement } from "./contentResolution"; + +type CellEditingControllerOptions = { + getRootElement: () => HTMLElement | null; + getYTextForCell: ( + blockId: string, + row: number, + col: number, + ) => FieldEditorTextLike | null; + attachElement: (element: HTMLElement) => boolean; + requestDomFocus: ( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ) => boolean; +}; + +export class CellEditingController { + private readonly options: CellEditingControllerOptions; + private coord: ActiveCellCoord | null = null; + + constructor(options: CellEditingControllerOptions) { + this.options = options; + } + + get activeCellCoord(): ActiveCellCoord | null { + return this.coord; + } + + setActiveCell(blockId: string, row: number, col: number): void { + this.coord = { blockId, row, col }; + } + + clear(): void { + this.coord = null; + } + + trySyncBackend(attempt = 0): void { + const coord = this.coord; + if (!coord) return; + + const ytext = this.options.getYTextForCell( + coord.blockId, + coord.row, + coord.col, + ); + if (!ytext) return; + + const cellEl = this.resolveCellElement( + coord.blockId, + coord.row, + coord.col, + ); + if (cellEl) { + this.options.attachElement(cellEl); + this.placeCaretInCell(cellEl); + return; + } + + if (attempt < 3) { + requestAnimationFrame(() => this.trySyncBackend(attempt + 1)); + } + } + + placeCaretInCell(cellEl: HTMLElement): void { + if ( + !this.options.requestDomFocus(cellEl, "cell", { + preventScroll: true, + }) + ) { + return; + } + const selection = cellEl.ownerDocument?.getSelection(); + if (!selection) return; + + const range = cellEl.ownerDocument.createRange(); + range.selectNodeContents(cellEl); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + + resolveInlineElement(blockId: string): HTMLElement | null { + const coord = this.coord; + if (coord?.blockId !== blockId) { + return null; + } + return this.resolveCellElement(coord.blockId, coord.row, coord.col); + } + + resolveActiveCellElement( + rootElement?: HTMLElement | null, + ): HTMLElement | null { + const coord = this.coord; + if (!coord) return null; + return this.resolveCellElement( + coord.blockId, + coord.row, + coord.col, + rootElement, + ); + } + + resolveCellElement( + blockId: string, + row: number, + col: number, + root?: HTMLElement | null, + ): HTMLElement | null { + return resolveCellInlineElement( + blockId, + row, + col, + root ?? this.options.getRootElement(), + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/commands.ts b/packages/rendering/dom/src/field-editor/commands.ts index 8987f05..4e2598f 100644 --- a/packages/rendering/dom/src/field-editor/commands.ts +++ b/packages/rendering/dom/src/field-editor/commands.ts @@ -1,751 +1,33 @@ -import { - INPUT_RULES_ENGINE_SLOT_KEY, - generateId, -} from "@pen/types"; -import type { DocumentOp, Editor } from "@pen/types"; -import { - toggleInlineMark as toggleInlineMarkCommand, - setInlineMark as setInlineMarkCommand, -} from "@pen/shortcuts"; -import { matchListInputRule } from "../utils/listInputRule"; -import { - getAdjacentVisibleBlockId, - isInsideParentIdContainer, -} from "../utils/parentIdTree"; -import { getEditorFlowCapability, isContinuousTextFlowCapability } from "../utils/flowCapabilities"; - -const ZERO_WIDTH_SPACE = "\u200B"; - -export interface SelectionRange { - start: number; - end: number; -} - -export interface SelectionTarget { - blockId: string; - anchorOffset: number; - focusOffset: number; - selectBlock?: boolean; -} - -type InlineTextLike = { - length: number; - toString(): string; -}; - -type BlockInputRuleEngine = { - tryMatch( - editor: Editor, - blockId: string, - insertedText: string, - options?: { offset?: number }, - ): DocumentOp[] | null; -}; - -// ── Enter action resolution ────────────────────────────────── - -type EnterAction = - | { action: "split"; newBlockType: string | undefined } - | { action: "convert"; newType: string } - | { action: "lift" } - | { action: "insert-text"; text: string }; - -type BackspaceAction = - | { action: "convert"; newType: string } - | { action: "delete"; targetBlockId: string } - | { action: "select-block"; targetBlockId: string } - | { action: "merge"; targetBlockId: string }; - -type DeleteDirection = "backward" | "forward"; - -const LIST_BLOCK_TYPES = new Set([ - "bulletListItem", - "numberedListItem", - "checkListItem", -]); - -const HEADING_TYPES = new Set(["heading"]); - -const CONTAINER_EXIT_TYPES = new Set(["blockquote", "callout"]); -const BACKSPACE_EXIT_TYPES = new Set([ - ...LIST_BLOCK_TYPES, - ...CONTAINER_EXIT_TYPES, - ...HEADING_TYPES, -]); - -function isBlockEmpty(ytext: InlineTextLike): boolean { - return getLogicalInlineLength(ytext) === 0; -} - -function getAdjacentEditableBlock( - editor: Editor, - blockId: string, - direction: "previous" | "next", -): ReturnType { - let adjacentBlockId = getAdjacentVisibleBlockId(editor, blockId, direction); - while (adjacentBlockId) { - const adjacentBlock = editor.getBlock(adjacentBlockId); - if ( - adjacentBlock && - isContinuousTextFlowCapability( - getEditorFlowCapability(editor, adjacentBlock.id), - ) - ) { - return adjacentBlock; - } - adjacentBlockId = getAdjacentVisibleBlockId( - editor, - adjacentBlockId, - direction, - ); - } - return null; -} - -export function getLogicalInlineLength(ytext: InlineTextLike): number { - const text = ytext.toString(); - if (!text || text === ZERO_WIDTH_SPACE) { - return 0; - } - return ytext.length; -} - -export function normalizeInlineRange( - ytext: InlineTextLike, - range: SelectionRange | null, -): SelectionRange | null { - if (!range) return null; - - return { - start: normalizeInlineOffset(ytext, range.start), - end: normalizeInlineOffset(ytext, range.end), - }; -} - -function getSelectionTarget( - blockId: string, - ytext: InlineTextLike, - range: SelectionRange | null, -): SelectionTarget { - const normalizedRange = normalizeInlineRange(ytext, range); - - return { - blockId, - anchorOffset: normalizedRange?.start ?? 0, - focusOffset: normalizedRange?.end ?? 0, - }; -} - -function isCollapsedRange(range: SelectionRange | null): boolean { - return !range || range.start === range.end; -} - -function getListIndent( - block: NonNullable>, -): number { - const rawIndent = block.props?.indent; - return typeof rawIndent === "number" && rawIndent >= 0 ? rawIndent : 0; -} - -function isListBlock( - block: ReturnType, -): block is NonNullable> { - return !!block && LIST_BLOCK_TYPES.has(block.type); -} - -export function moveCaretAcrossBlocks( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - direction: "previous" | "next"; - }, -): SelectionTarget | null { - const { blockId, ytext, direction } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!isCollapsedRange(range)) return null; - - const currentOffset = range?.start ?? 0; - const logicalLength = getLogicalInlineLength(ytext); - const isAtBoundary = - direction === "previous" - ? currentOffset === 0 - : currentOffset === logicalLength; - if (!isAtBoundary) return null; - - const immediateId = getAdjacentVisibleBlockId(editor, blockId, direction); - if (!immediateId) return null; - - if ( - !isContinuousTextFlowCapability( - getEditorFlowCapability(editor, immediateId), - ) - ) { - return { - blockId: immediateId, - anchorOffset: 0, - focusOffset: 0, - selectBlock: true, - }; - } - - const adjacentBlock = editor.getBlock(immediateId); - if (!adjacentBlock) return null; - - const targetOffset = direction === "previous" ? adjacentBlock.length() : 0; - return { - blockId: adjacentBlock.id, - anchorOffset: targetOffset, - focusOffset: targetOffset, - }; -} - -export function applyListTabBehavior( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - shiftKey: boolean; - }, -): SelectionTarget | null { - const { blockId, ytext, range, shiftKey } = options; - const block = editor.getBlock(blockId); - if (!isListBlock(block)) { - return null; - } - - const currentIndent = getListIndent(block); - let nextIndent = currentIndent; - - if (shiftKey) { - nextIndent = Math.max(0, currentIndent - 1); - } else { - const previousBlockId = getAdjacentVisibleBlockId(editor, blockId, "previous"); - const previousBlock = previousBlockId - ? editor.getBlock(previousBlockId) - : null; - const sharesParent = - previousBlockId !== null && - editor.documentState.parentOf(previousBlockId) === - editor.documentState.parentOf(blockId); - - if ( - isListBlock(previousBlock) && - sharesParent && - getListIndent(previousBlock) >= currentIndent - ) { - nextIndent = currentIndent + 1; - } - } - - if (nextIndent === currentIndent) { - return null; - } - - editor.apply( - [ - { - type: "update-block", - blockId, - props: { indent: nextIndent }, - } as DocumentOp, - ], - { origin: "user" }, - ); - - return getSelectionTarget(blockId, ytext, range); -} - -export function resolveBackspaceAction( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - }, -): BackspaceAction | null { - const { blockId, ytext } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!isCollapsedRange(range)) return null; - if ((range?.start ?? 0) !== 0) return null; - if ( - !isContinuousTextFlowCapability(getEditorFlowCapability(editor, blockId)) - ) { - return null; - } - - const block = editor.getBlock(blockId); - if (!block) return null; - - if (isBlockEmpty(ytext) && block.type === "toggle" && block.children.length === 0) { - const previousBlock = getAdjacentEditableBlock(editor, blockId, "previous"); - if (previousBlock) { - return { - action: "delete", - targetBlockId: previousBlock.id, - }; - } - return { action: "convert", newType: "paragraph" }; - } - - if (isBlockEmpty(ytext) && BACKSPACE_EXIT_TYPES.has(block.type)) { - return { action: "convert", newType: "paragraph" }; - } - - const immediateBlockId = getAdjacentVisibleBlockId(editor, blockId, "previous"); - if ( - immediateBlockId && - !isContinuousTextFlowCapability( - getEditorFlowCapability(editor, immediateBlockId), - ) - ) { - return { - action: "select-block", - targetBlockId: immediateBlockId, - }; - } - - const previousBlock = getAdjacentEditableBlock(editor, blockId, "previous"); - if (!previousBlock) return null; - - return { - action: "merge", - targetBlockId: previousBlock.id, - }; -} - -export function applyBackspaceBehavior( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - }, -): SelectionTarget | null { - const { blockId, ytext } = options; - const action = resolveBackspaceAction(editor, options); - if (!action) return null; - - if (action.action === "convert") { - return convertBlock(editor, { - blockId, - newType: action.newType, - }); - } - - if (action.action === "select-block") { - return { - blockId: action.targetBlockId, - anchorOffset: 0, - focusOffset: 0, - selectBlock: true, - }; - } - - const previousBlock = editor.getBlock(action.targetBlockId); - if (!previousBlock) return null; - - const targetOffset = previousBlock.length(); - if (action.action === "delete" || getLogicalInlineLength(ytext) === 0) { - editor.apply([ - { - type: "delete-block", - blockId, - } as DocumentOp, - ]); - } else { - editor.apply([ - { - type: "merge-blocks", - targetBlockId: previousBlock.id, - sourceBlockId: blockId, - } as DocumentOp, - ]); - } - - return { - blockId: previousBlock.id, - anchorOffset: targetOffset, - focusOffset: targetOffset, - }; -} - -function getCollapsedTextSelectionTarget(editor: Editor): SelectionTarget | null { - const selection = editor.selection; - if (!selection || selection.type !== "text") { - return null; - } - - return { - blockId: selection.focus.blockId, - anchorOffset: selection.focus.offset, - focusOffset: selection.focus.offset, - }; -} - -export function applyDeleteBehavior( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - direction: DeleteDirection; - }, -): SelectionTarget | null { - const { blockId, ytext, direction } = options; - const range = normalizeInlineRange(ytext, options.range); - if (!range) return null; - - if (!isCollapsedRange(range)) { - editor.selectText(blockId, range.start, range.end); - editor.deleteSelection({ origin: "user" }); - return ( - getCollapsedTextSelectionTarget(editor) ?? { - blockId, - anchorOffset: range.start, - focusOffset: range.start, - } - ); - } - - if (direction === "backward") { - return applyBackspaceBehavior(editor, { - blockId, - ytext, - range, - }); - } - - return null; -} - -export function mergeBackwardAtBlockStart( - editor: Editor, - options: { - blockId: string; - ytext: InlineTextLike; - range: SelectionRange | null; - }, -): SelectionTarget | null { - return applyBackspaceBehavior(editor, options); -} - -export function resolveEnterAction( - editor: Editor, - blockId: string, - inputMode: "richtext" | "code" | "table" | "none", - ytext: { length: number; toString(): string }, -): EnterAction | null { - if (inputMode === "code") { - return { action: "insert-text", text: "\n" }; - } - - if (inputMode !== "richtext") { - return null; - } - - const block = editor.getBlock(blockId); - if (!block) return null; - - const blockType = block.type; - const empty = isBlockEmpty(ytext); - - if (empty && LIST_BLOCK_TYPES.has(blockType)) { - return { action: "convert", newType: "paragraph" }; - } - - if (empty && CONTAINER_EXIT_TYPES.has(blockType)) { - return { action: "convert", newType: "paragraph" }; - } - - if (empty && isInsideParentIdContainer(editor, blockId)) { - return { action: "lift" }; - } - - if (HEADING_TYPES.has(blockType)) { - return { action: "split", newBlockType: "paragraph" }; - } - - return { action: "split", newBlockType: undefined }; -} - -// ── Offset normalization ───────────────────────────────────── - -export function normalizeInlineOffset( - ytext: InlineTextLike, - offset: number, -): number { - return Math.max(0, Math.min(offset, getLogicalInlineLength(ytext))); -} - -export function toggleInlineMark(editor: Editor, markType: string): boolean { - return toggleInlineMarkCommand(editor, markType); -} - -export function setInlineMark( - editor: Editor, - markType: string, - value: Record | null, -): boolean { - return setInlineMarkCommand(editor, markType, value); -} - -// ── Commands ───────────────────────────────────────────────── - -export function splitBlockAtOffset( - editor: Editor, - options: { - blockId: string; - offset: number; - newBlockType?: string; - }, -): SelectionTarget { - const { blockId, offset, newBlockType } = options; - const newBlockId = generateId(); - - editor.apply([ - { - type: "split-block", - blockId, - offset, - newBlockId, - newBlockType, - } as DocumentOp, - ]); - - return { - blockId: newBlockId, - anchorOffset: 0, - focusOffset: 0, - }; -} - -export function convertBlock( - editor: Editor, - options: { - blockId: string; - newType: string; - newProps?: Record; - }, -): SelectionTarget { - editor.apply(getConvertBlockOps(editor, options), { origin: "user" }); - - return { - blockId: options.blockId, - anchorOffset: 0, - focusOffset: 0, - }; -} - -export function getConvertBlockOps( - editor: Editor, - options: { - blockId: string; - newType: string; - newProps?: Record; - }, -): DocumentOp[] { - const existingParentId = editor.documentState.parentOf(options.blockId); - const ops: DocumentOp[] = [ - { - type: "convert-block", - blockId: options.blockId, - newType: options.newType, - newProps: options.newProps, - } as DocumentOp, - ]; - - if (existingParentId) { - ops.push({ - type: "update-block", - blockId: options.blockId, - props: { parentId: existingParentId }, - } as DocumentOp); - } - - return ops; -} - -export function insertTextAtRange( - editor: Editor, - options: { - blockId: string; - range: SelectionRange | null; - text: string; - }, -): SelectionTarget { - const { blockId, range, text } = options; - const start = range?.start ?? 0; - const end = range?.end ?? start; - const ops: DocumentOp[] = []; - - if (end > start) { - ops.push({ - type: "delete-text", - blockId, - offset: start, - length: end - start, - }); - } - - if (text.length > 0) { - ops.push({ - type: "insert-text", - blockId, - offset: start, - text, - }); - } - - if (ops.length > 0) { - editor.apply(ops, { origin: "user" }); - } - - const nextOffset = start + text.length; - return { - blockId, - anchorOffset: nextOffset, - focusOffset: nextOffset, - }; -} - -export function applyListInputRule( - editor: Editor, - options: { - blockId: string; - range: SelectionRange | null; - text: string; - }, -): SelectionTarget | null { - const { blockId, range, text } = options; - if (!range || range.start !== range.end) { - return null; - } - - const block = editor.getBlock(blockId); - if (!block) { - return null; - } - - const inputRuleEngine = - editor.internals.getSlot(INPUT_RULES_ENGINE_SLOT_KEY) ?? - null; - if (inputRuleEngine) { - const ops = inputRuleEngine.tryMatch(editor, blockId, text, { - offset: range.start, - }); - if (ops) { - editor.apply(ops, { origin: "input-rule" }); - return { - blockId, - anchorOffset: 0, - focusOffset: 0, - }; - } - } - - if (block.type !== "paragraph") { - return null; - } - - const match = matchListInputRule(block.textContent(), range, text); - if (!match) { - return null; - } - - editor.apply( - [ - { - type: "delete-text", - blockId, - offset: match.deleteRange.start, - length: match.deleteRange.end - match.deleteRange.start, - } as DocumentOp, - { - type: "convert-block", - blockId, - newType: match.blockType, - newProps: match.newProps, - } as DocumentOp, - ], - { origin: "input-rule" }, - ); - - return { - blockId, - anchorOffset: 0, - focusOffset: 0, - }; -} - -export function applyEnterBehavior( - editor: Editor, - options: { - blockId: string; - inputMode: "richtext" | "code" | "table" | "none"; - ytext: { - length: number; - toString(): string; - insert(offset: number, text: string): void; - delete(offset: number, length: number): void; - }; - range: SelectionRange | null; - }, -): SelectionTarget | null { - const { blockId, inputMode, ytext, range } = options; - - const enterAction = resolveEnterAction(editor, blockId, inputMode, ytext); - if (!enterAction) return null; - - switch (enterAction.action) { - case "insert-text": - return insertTextAtRange(editor, { - blockId, - range, - text: enterAction.text, - }); - - case "convert": - return convertBlock(editor, { - blockId, - newType: enterAction.newType, - }); - - case "lift": - return liftBlockOutOfParent(editor, { blockId }); - - case "split": - return splitBlockAtOffset(editor, { - blockId, - offset: normalizeInlineOffset( - ytext, - range?.start ?? ytext.length, - ), - newBlockType: enterAction.newBlockType, - }); - } -} - -function liftBlockOutOfParent( - editor: Editor, - options: { blockId: string }, -): SelectionTarget { - editor.apply( - [ - { - type: "update-block", - blockId: options.blockId, - props: { parentId: null }, - } as DocumentOp, - ], - { origin: "user" }, - ); - - return { - blockId: options.blockId, - anchorOffset: 0, - focusOffset: 0, - }; -} +export type { + InlineTextLike, + SelectionRange, + SelectionTarget, +} from "./commandsShared"; +export { + getLogicalInlineLength, + normalizeInlineRange, +} from "./commandsShared"; +export { + applyListTabBehavior, + moveCaretAcrossBlocks, +} from "./commandsNavigation"; +export { + applyBackspaceBehavior, + applyDeleteBehavior, + mergeBackwardAtBlockStart, + resolveBackspaceAction, +} from "./commandsDelete"; +export { + applyListInputRule, + convertBlock, + getConvertBlockOps, + insertTextAtRange, + normalizeInlineOffset, + setInlineMark, + splitBlockAtOffset, + toggleInlineMark, +} from "./commandsBlock"; +export { + applyEnterBehavior, + resolveEnterAction, +} from "./commandsEnter"; diff --git a/packages/rendering/dom/src/field-editor/commandsBlock.ts b/packages/rendering/dom/src/field-editor/commandsBlock.ts new file mode 100644 index 0000000..56141bd --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsBlock.ts @@ -0,0 +1,222 @@ +import { INPUT_RULES_ENGINE_SLOT_KEY, generateId } from "@pen/types"; +import type { DocumentOp, Editor } from "@pen/types"; +import { + toggleInlineMark as toggleInlineMarkCommand, + setInlineMark as setInlineMarkCommand, +} from "@pen/shortcuts"; +import { matchListInputRule } from "../utils/listInputRule"; +import { + getLogicalInlineLength, + type BlockInputRuleEngine, + type InlineTextLike, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; + +export function normalizeInlineOffset( + ytext: InlineTextLike, + offset: number, +): number { + return Math.max(0, Math.min(offset, getLogicalInlineLength(ytext))); +} + +export function toggleInlineMark(editor: Editor, markType: string): boolean { + return toggleInlineMarkCommand(editor, markType); +} + +export function setInlineMark( + editor: Editor, + markType: string, + value: Record | null, +): boolean { + return setInlineMarkCommand(editor, markType, value); +} + +// ── Commands ───────────────────────────────────────────────── + +export function splitBlockAtOffset( + editor: Editor, + options: { + blockId: string; + offset: number; + newBlockType?: string; + }, +): SelectionTarget { + const { blockId, offset, newBlockType } = options; + const newBlockId = generateId(); + + editor.apply([ + { + type: "split-block", + blockId, + offset, + newBlockId, + newBlockType, + } as DocumentOp, + ]); + + return { + blockId: newBlockId, + anchorOffset: 0, + focusOffset: 0, + }; +} + +export function convertBlock( + editor: Editor, + options: { + blockId: string; + newType: string; + newProps?: Record; + }, +): SelectionTarget { + editor.apply(getConvertBlockOps(editor, options), { origin: "user" }); + + return { + blockId: options.blockId, + anchorOffset: 0, + focusOffset: 0, + }; +} + +export function getConvertBlockOps( + editor: Editor, + options: { + blockId: string; + newType: string; + newProps?: Record; + }, +): DocumentOp[] { + const existingParentId = editor.documentState.parentOf(options.blockId); + const ops: DocumentOp[] = [ + { + type: "convert-block", + blockId: options.blockId, + newType: options.newType, + newProps: options.newProps, + } as DocumentOp, + ]; + + if (existingParentId) { + ops.push({ + type: "update-block", + blockId: options.blockId, + props: { parentId: existingParentId }, + } as DocumentOp); + } + + return ops; +} + +export function insertTextAtRange( + editor: Editor, + options: { + blockId: string; + range: SelectionRange | null; + text: string; + }, +): SelectionTarget { + const { blockId, range, text } = options; + const start = range?.start ?? 0; + const end = range?.end ?? start; + const ops: DocumentOp[] = []; + + if (end > start) { + ops.push({ + type: "delete-text", + blockId, + offset: start, + length: end - start, + }); + } + + if (text.length > 0) { + ops.push({ + type: "insert-text", + blockId, + offset: start, + text, + }); + } + + if (ops.length > 0) { + editor.apply(ops, { origin: "user" }); + } + + const nextOffset = start + text.length; + return { + blockId, + anchorOffset: nextOffset, + focusOffset: nextOffset, + }; +} + +export function applyListInputRule( + editor: Editor, + options: { + blockId: string; + range: SelectionRange | null; + text: string; + }, +): SelectionTarget | null { + const { blockId, range, text } = options; + if (!range || range.start !== range.end) { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + + const inputRuleEngine = + editor.internals.getSlot( + INPUT_RULES_ENGINE_SLOT_KEY, + ) ?? null; + if (inputRuleEngine) { + const ops = inputRuleEngine.tryMatch(editor, blockId, text, { + offset: range.start, + }); + if (ops) { + editor.apply(ops, { origin: "input-rule" }); + return { + blockId, + anchorOffset: 0, + focusOffset: 0, + }; + } + } + + if (block.type !== "paragraph") { + return null; + } + + const match = matchListInputRule(block.textContent(), range, text); + if (!match) { + return null; + } + + editor.apply( + [ + { + type: "delete-text", + blockId, + offset: match.deleteRange.start, + length: match.deleteRange.end - match.deleteRange.start, + } as DocumentOp, + { + type: "convert-block", + blockId, + newType: match.blockType, + newProps: match.newProps, + } as DocumentOp, + ], + { origin: "input-rule" }, + ); + + return { + blockId, + anchorOffset: 0, + focusOffset: 0, + }; +} diff --git a/packages/rendering/dom/src/field-editor/commandsDelete.ts b/packages/rendering/dom/src/field-editor/commandsDelete.ts new file mode 100644 index 0000000..1b31682 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsDelete.ts @@ -0,0 +1,220 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import { getAdjacentVisibleBlockId } from "../utils/parentIdTree"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; +import { + BACKSPACE_EXIT_TYPES, + getAdjacentEditableBlock, + getInlineNodeSelectionTarget, + getLogicalInlineLength, + isBlockEmpty, + isCollapsedRange, + normalizeInlineRange, + type BackspaceAction, + type DeleteDirection, + type InlineTextLike, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; +import { convertBlock } from "./commandsBlock"; + +export function resolveBackspaceAction( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + }, +): BackspaceAction | null { + const { blockId, ytext } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!isCollapsedRange(range)) return null; + if ((range?.start ?? 0) !== 0) return null; + if ( + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, blockId), + ) + ) { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) return null; + + if ( + isBlockEmpty(ytext) && + block.type === "toggle" && + block.children.length === 0 + ) { + const previousBlock = getAdjacentEditableBlock( + editor, + blockId, + "previous", + ); + if (previousBlock) { + return { + action: "delete", + targetBlockId: previousBlock.id, + }; + } + return { action: "convert", newType: "paragraph" }; + } + + if (isBlockEmpty(ytext) && BACKSPACE_EXIT_TYPES.has(block.type)) { + return { action: "convert", newType: "paragraph" }; + } + + const immediateBlockId = getAdjacentVisibleBlockId( + editor, + blockId, + "previous", + ); + if ( + immediateBlockId && + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, immediateBlockId), + ) + ) { + return { + action: "select-block", + targetBlockId: immediateBlockId, + }; + } + + const previousBlock = getAdjacentEditableBlock(editor, blockId, "previous"); + if (!previousBlock) return null; + + return { + action: "merge", + targetBlockId: previousBlock.id, + }; +} + +export function applyBackspaceBehavior( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + }, +): SelectionTarget | null { + const { blockId, ytext } = options; + const action = resolveBackspaceAction(editor, options); + if (!action) return null; + + if (action.action === "convert") { + return convertBlock(editor, { + blockId, + newType: action.newType, + }); + } + + if (action.action === "select-block") { + return { + blockId: action.targetBlockId, + anchorOffset: 0, + focusOffset: 0, + selectBlock: true, + }; + } + + const previousBlock = editor.getBlock(action.targetBlockId); + if (!previousBlock) return null; + + const targetOffset = previousBlock.length(); + if (action.action === "delete" || getLogicalInlineLength(ytext) === 0) { + editor.apply([ + { + type: "delete-block", + blockId, + } as DocumentOp, + ]); + } else { + editor.apply([ + { + type: "merge-blocks", + targetBlockId: previousBlock.id, + sourceBlockId: blockId, + } as DocumentOp, + ]); + } + + return { + blockId: previousBlock.id, + anchorOffset: targetOffset, + focusOffset: targetOffset, + }; +} + +function getCollapsedTextSelectionTarget( + editor: Editor, +): SelectionTarget | null { + const selection = editor.selection; + if (!selection || selection.type !== "text") { + return null; + } + + return { + blockId: selection.focus.blockId, + anchorOffset: selection.focus.offset, + focusOffset: selection.focus.offset, + }; +} + +export function applyDeleteBehavior( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + direction: DeleteDirection; + }, +): SelectionTarget | null { + const { blockId, ytext, direction } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!range) return null; + + if (!isCollapsedRange(range)) { + editor.selectText(blockId, range.start, range.end); + editor.deleteSelection({ origin: "user" }); + return ( + getCollapsedTextSelectionTarget(editor) ?? { + blockId, + anchorOffset: range.start, + focusOffset: range.start, + } + ); + } + + const inlineNodeTarget = getInlineNodeSelectionTarget(editor, { + blockId, + offset: range.start, + direction, + }); + if (inlineNodeTarget) { + return inlineNodeTarget; + } + + if (direction === "backward") { + return applyBackspaceBehavior(editor, { + blockId, + ytext, + range, + }); + } + + return null; +} + +export function mergeBackwardAtBlockStart( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + }, +): SelectionTarget | null { + return applyBackspaceBehavior(editor, options); +} diff --git a/packages/rendering/dom/src/field-editor/commandsEnter.ts b/packages/rendering/dom/src/field-editor/commandsEnter.ts new file mode 100644 index 0000000..0749ad0 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsEnter.ts @@ -0,0 +1,126 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import { isInsideParentIdContainer } from "../utils/parentIdTree"; +import { + CONTAINER_EXIT_TYPES, + HEADING_TYPES, + LIST_BLOCK_TYPES, + isBlockEmpty, + type EnterAction, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; +import { + convertBlock, + insertTextAtRange, + normalizeInlineOffset, + splitBlockAtOffset, +} from "./commandsBlock"; + +export function resolveEnterAction( + editor: Editor, + blockId: string, + inputMode: "richtext" | "code" | "table" | "none", + ytext: { length: number; toString(): string }, +): EnterAction | null { + if (inputMode === "code") { + return { action: "insert-text", text: "\n" }; + } + + if (inputMode !== "richtext") { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) return null; + + const blockType = block.type; + const empty = isBlockEmpty(ytext); + + if (empty && LIST_BLOCK_TYPES.has(blockType)) { + return { action: "convert", newType: "paragraph" }; + } + + if (empty && CONTAINER_EXIT_TYPES.has(blockType)) { + return { action: "convert", newType: "paragraph" }; + } + + if (empty && isInsideParentIdContainer(editor, blockId)) { + return { action: "lift" }; + } + + if (HEADING_TYPES.has(blockType)) { + return { action: "split", newBlockType: "paragraph" }; + } + + return { action: "split", newBlockType: undefined }; +} + +export function applyEnterBehavior( + editor: Editor, + options: { + blockId: string; + inputMode: "richtext" | "code" | "table" | "none"; + ytext: { + length: number; + toString(): string; + insert(offset: number, text: string): void; + delete(offset: number, length: number): void; + }; + range: SelectionRange | null; + }, +): SelectionTarget | null { + const { blockId, inputMode, ytext, range } = options; + + const enterAction = resolveEnterAction(editor, blockId, inputMode, ytext); + if (!enterAction) return null; + + switch (enterAction.action) { + case "insert-text": + return insertTextAtRange(editor, { + blockId, + range, + text: enterAction.text, + }); + + case "convert": + return convertBlock(editor, { + blockId, + newType: enterAction.newType, + }); + + case "lift": + return liftBlockOutOfParent(editor, { blockId }); + + case "split": + return splitBlockAtOffset(editor, { + blockId, + offset: normalizeInlineOffset( + ytext, + range?.start ?? ytext.length, + ), + newBlockType: enterAction.newBlockType, + }); + } +} + +function liftBlockOutOfParent( + editor: Editor, + options: { blockId: string }, +): SelectionTarget { + editor.apply( + [ + { + type: "update-block", + blockId: options.blockId, + props: { parentId: null }, + } as DocumentOp, + ], + { origin: "user" }, + ); + + return { + blockId: options.blockId, + anchorOffset: 0, + focusOffset: 0, + }; +} diff --git a/packages/rendering/dom/src/field-editor/commandsNavigation.ts b/packages/rendering/dom/src/field-editor/commandsNavigation.ts new file mode 100644 index 0000000..a91af5f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsNavigation.ts @@ -0,0 +1,126 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import { getAdjacentVisibleBlockId } from "../utils/parentIdTree"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; +import { + getListIndent, + getLogicalInlineLength, + getSelectionTarget, + isCollapsedRange, + isListBlock, + normalizeInlineRange, + type InlineTextLike, + type SelectionRange, + type SelectionTarget, +} from "./commandsShared"; + +export function moveCaretAcrossBlocks( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + direction: "previous" | "next"; + }, +): SelectionTarget | null { + const { blockId, ytext, direction } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!isCollapsedRange(range)) return null; + + const currentOffset = range?.start ?? 0; + const logicalLength = getLogicalInlineLength(ytext); + const isAtBoundary = + direction === "previous" + ? currentOffset === 0 + : currentOffset === logicalLength; + if (!isAtBoundary) return null; + + const immediateId = getAdjacentVisibleBlockId(editor, blockId, direction); + if (!immediateId) return null; + + if ( + !isContinuousTextFlowCapability( + getEditorFlowCapability(editor, immediateId), + ) + ) { + return { + blockId: immediateId, + anchorOffset: 0, + focusOffset: 0, + selectBlock: true, + }; + } + + const adjacentBlock = editor.getBlock(immediateId); + if (!adjacentBlock) return null; + + const targetOffset = direction === "previous" ? adjacentBlock.length() : 0; + return { + blockId: adjacentBlock.id, + anchorOffset: targetOffset, + focusOffset: targetOffset, + }; +} + +export function applyListTabBehavior( + editor: Editor, + options: { + blockId: string; + ytext: InlineTextLike; + range: SelectionRange | null; + shiftKey: boolean; + }, +): SelectionTarget | null { + const { blockId, ytext, range, shiftKey } = options; + const block = editor.getBlock(blockId); + if (!isListBlock(block)) { + return null; + } + + const currentIndent = getListIndent(block); + let nextIndent = currentIndent; + + if (shiftKey) { + nextIndent = Math.max(0, currentIndent - 1); + } else { + const previousBlockId = getAdjacentVisibleBlockId( + editor, + blockId, + "previous", + ); + const previousBlock = previousBlockId + ? editor.getBlock(previousBlockId) + : null; + const sharesParent = + previousBlockId !== null && + editor.documentState.parentOf(previousBlockId) === + editor.documentState.parentOf(blockId); + + if ( + isListBlock(previousBlock) && + sharesParent && + getListIndent(previousBlock) >= currentIndent + ) { + nextIndent = currentIndent + 1; + } + } + + if (nextIndent === currentIndent) { + return null; + } + + editor.apply( + [ + { + type: "update-block", + blockId, + props: { indent: nextIndent }, + } as DocumentOp, + ], + { origin: "user" }, + ); + + return getSelectionTarget(blockId, ytext, range); +} diff --git a/packages/rendering/dom/src/field-editor/commandsShared.ts b/packages/rendering/dom/src/field-editor/commandsShared.ts new file mode 100644 index 0000000..c0016c8 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/commandsShared.ts @@ -0,0 +1,227 @@ +import { INPUT_RULES_ENGINE_SLOT_KEY, generateId } from "@pen/types"; +import type { DocumentOp, Editor } from "@pen/types"; +import { + toggleInlineMark as toggleInlineMarkCommand, + setInlineMark as setInlineMarkCommand, +} from "@pen/shortcuts"; +import { matchListInputRule } from "../utils/listInputRule"; +import { + getAdjacentVisibleBlockId, + isInsideParentIdContainer, +} from "../utils/parentIdTree"; +import { + getEditorFlowCapability, + isContinuousTextFlowCapability, +} from "../utils/flowCapabilities"; + +export const ZERO_WIDTH_SPACE = "\u200B"; + +export interface SelectionRange { + start: number; + end: number; +} + +export interface SelectionTarget { + blockId: string; + anchorOffset: number; + focusOffset: number; + selectBlock?: boolean; +} + +export type InlineTextLike = { + length: number; + toString(): string; + toDelta?(): Array<{ insert?: string | Record }>; +}; + +export type BlockInputRuleEngine = { + tryMatch( + editor: Editor, + blockId: string, + insertedText: string, + options?: { offset?: number }, + ): DocumentOp[] | null; +}; + +// ── Enter action resolution ────────────────────────────────── + +export type EnterAction = + | { action: "split"; newBlockType: string | undefined } + | { action: "convert"; newType: string } + | { action: "lift" } + | { action: "insert-text"; text: string }; + +export type BackspaceAction = + | { action: "convert"; newType: string } + | { action: "delete"; targetBlockId: string } + | { action: "select-block"; targetBlockId: string } + | { action: "merge"; targetBlockId: string }; + +export type DeleteDirection = "backward" | "forward"; + +export const LIST_BLOCK_TYPES = new Set([ + "bulletListItem", + "numberedListItem", + "checkListItem", +]); + +export const HEADING_TYPES = new Set(["heading"]); + +export const CONTAINER_EXIT_TYPES = new Set(["blockquote", "callout"]); +export const BACKSPACE_EXIT_TYPES = new Set([ + ...LIST_BLOCK_TYPES, + ...CONTAINER_EXIT_TYPES, + ...HEADING_TYPES, +]); + +export function isBlockEmpty(ytext: InlineTextLike): boolean { + return getLogicalInlineLength(ytext) === 0; +} + +export function getAdjacentEditableBlock( + editor: Editor, + blockId: string, + direction: "previous" | "next", +): ReturnType { + let adjacentBlockId = getAdjacentVisibleBlockId(editor, blockId, direction); + while (adjacentBlockId) { + const adjacentBlock = editor.getBlock(adjacentBlockId); + if ( + adjacentBlock && + isContinuousTextFlowCapability( + getEditorFlowCapability(editor, adjacentBlock.id), + ) + ) { + return adjacentBlock; + } + adjacentBlockId = getAdjacentVisibleBlockId( + editor, + adjacentBlockId, + direction, + ); + } + return null; +} + +export function getLogicalInlineLength(ytext: InlineTextLike): number { + const delta = ytext.toDelta?.(); + if (delta) { + return delta.reduce((length, entry) => { + if (typeof entry.insert === "string") { + return ( + length + + (entry.insert === ZERO_WIDTH_SPACE + ? 0 + : entry.insert.length) + ); + } + return entry.insert ? length + 1 : length; + }, 0); + } + + const text = ytext.toString(); + if (!text || text === ZERO_WIDTH_SPACE) { + return 0; + } + return ytext.length; +} + +export function normalizeInlineOffset( + ytext: InlineTextLike, + offset: number, +): number { + return Math.max(0, Math.min(offset, getLogicalInlineLength(ytext))); +} + +export function normalizeInlineRange( + ytext: InlineTextLike, + range: SelectionRange | null, +): SelectionRange | null { + if (!range) return null; + + return { + start: normalizeInlineOffset(ytext, range.start), + end: normalizeInlineOffset(ytext, range.end), + }; +} + +export function getSelectionTarget( + blockId: string, + ytext: InlineTextLike, + range: SelectionRange | null, +): SelectionTarget { + const normalizedRange = normalizeInlineRange(ytext, range); + + return { + blockId, + anchorOffset: normalizedRange?.start ?? 0, + focusOffset: normalizedRange?.end ?? 0, + }; +} + +export function isCollapsedRange(range: SelectionRange | null): boolean { + return !range || range.start === range.end; +} + +export function getInlineNodeSelectionTarget( + editor: Editor, + options: { + blockId: string; + offset: number; + direction: DeleteDirection; + }, +): SelectionTarget | null { + const block = editor.getBlock(options.blockId); + if (!block) { + return null; + } + + let currentOffset = 0; + for (const delta of block.inlineDeltas()) { + const length = + typeof delta.insert === "string" ? delta.insert.length : 1; + const nextOffset = currentOffset + length; + const isInlineNode = typeof delta.insert !== "string"; + + if ( + isInlineNode && + options.direction === "backward" && + options.offset === nextOffset + ) { + return { + blockId: options.blockId, + anchorOffset: currentOffset, + focusOffset: nextOffset, + }; + } + + if ( + isInlineNode && + options.direction === "forward" && + options.offset === currentOffset + ) { + return { + blockId: options.blockId, + anchorOffset: currentOffset, + focusOffset: nextOffset, + }; + } + + currentOffset = nextOffset; + } + + return null; +} + +export function getListIndent( + block: NonNullable>, +): number { + const rawIndent = block.props?.indent; + return typeof rawIndent === "number" && rawIndent >= 0 ? rawIndent : 0; +} + +export function isListBlock( + block: ReturnType, +): block is NonNullable> { + return !!block && LIST_BLOCK_TYPES.has(block.type); +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts index 43b3ae7..c524dad 100644 --- a/packages/rendering/dom/src/field-editor/contenteditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/contenteditableBackend.ts @@ -1,1174 +1,3 @@ -import type { DocumentOp, Editor, InlineDecoration, InputBackend } from "@pen/types"; -import type { FieldEditorInputController } from "./controller"; -import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; -import { - computeTextDiff, - domPointToOffset, - domSelectionToEditor, - editorSelectionToDOM, - extractTextFromDOM, - getSelectionOffsets, -} from "./selectionBridge"; -import { normalizeSelectionFormation } from "../utils/selectionFormation"; -import type { PasteImporters } from "../types/paste"; -import { handlePaste, handleCopy, handleCut } from "./clipboard"; -import { - applyListInputRule, - applyDeleteBehavior, - applyEnterBehavior, - toggleInlineMark, -} from "./commands"; -import { handleFieldEditorKeyDown } from "./keyHandling"; -import { isHistoryTransactionOrigin } from "./historyOrigin"; -import type { - FieldEditorDelta, - FieldEditorObserver, - FieldEditorTextChangeEvent, - FieldEditorTextLike, -} from "./crdt"; - -export class ContentEditableBackend implements InputBackend { - private element: HTMLElement | null = null; - private ytext: FieldEditorTextLike | null = null; - private observer: FieldEditorObserver | null = null; - private mutationObserver: MutationObserver | null = null; - private isApplyingSelection = 0; - private isComposing = false; - private compositionStartTimestamp = 0; - private compositionStartText: string | null = null; - private deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }> = []; - private pendingSelectionOverride: { - blockId: string; - anchorOffset: number; - focusOffset: number; - cell?: { row: number; col: number }; - } | null = null; - private activeCellSelection: { start: number; end: number } | null = null; - private editor: Editor; - private fieldEditor: FieldEditorInputController; - - constructor(editor: Editor, fieldEditor: FieldEditorInputController) { - this.editor = editor; - this.fieldEditor = fieldEditor; - } - - activate(element: HTMLElement, ytext: unknown): void { - this.element = element; - this.ytext = ytext as FieldEditorTextLike; - - element.contentEditable = "true"; - this.isApplyingSelection++; - this.isComposing = false; - this.compositionStartText = null; - this.activeCellSelection = null; - this.fieldEditor.setComposing(false); - - element.addEventListener("beforeinput", this.handleBeforeInput); - element.addEventListener( - "compositionstart", - this.handleCompositionStart, - ); - element.addEventListener("compositionend", this.handleCompositionEnd); - element.addEventListener("keydown", this.handleKeyDown); - element.addEventListener("copy", this.handleCopyEvent); - element.addEventListener("cut", this.handleCutEvent); - element.addEventListener("dragstart", this.handleDragStart); - element.addEventListener("drop", this.handleDrop); - element.ownerDocument?.addEventListener( - "selectionchange", - this.handleSelectionChange, - ); - - this.mutationObserver = new MutationObserver(this.handleMutations); - this.mutationObserver.observe(element, { - childList: true, - subtree: true, - characterData: true, - characterDataOldValue: true, - }); - - this.observer = (event) => this.handleYTextChange(event); - this.ytext.observe(this.observer); - - fullReconcileToDOM(this.ytext, element, this.editor.schema, { - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.restoreDOMSelectionFromEditor(); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); - } - - deactivate(): void { - if (this.element) { - this.element.contentEditable = "false"; - this.element.removeEventListener( - "beforeinput", - this.handleBeforeInput, - ); - this.element.removeEventListener( - "compositionstart", - this.handleCompositionStart, - ); - this.element.removeEventListener( - "compositionend", - this.handleCompositionEnd, - ); - this.element.removeEventListener("keydown", this.handleKeyDown); - this.element.removeEventListener("copy", this.handleCopyEvent); - this.element.removeEventListener("cut", this.handleCutEvent); - this.element.removeEventListener("dragstart", this.handleDragStart); - this.element.removeEventListener("drop", this.handleDrop); - this.element.ownerDocument?.removeEventListener( - "selectionchange", - this.handleSelectionChange, - ); - } - if (this.mutationObserver) { - this.mutationObserver.disconnect(); - this.mutationObserver = null; - } - if (this.observer && this.ytext) { - this.ytext.unobserve(this.observer); - } - this.element = null; - this.ytext = null; - this.observer = null; - this.deferredRemoteDeltas = []; - this.isApplyingSelection = 0; - this.isComposing = false; - this.compositionStartText = null; - this.activeCellSelection = null; - this.fieldEditor.setComposing(false); - } - - updateSelection(_relPos: unknown): void { - this.restoreDOMSelectionFromEditor(); - } - - private _getActiveCellCoord(blockId: string): { - blockId: string; - row: number; - col: number; - } | null { - const coord = this.fieldEditor.activeCellCoord; - if (!coord || coord.blockId !== blockId) { - return null; - } - return coord; - } - - applyInlineTextEdit(options: { - blockId: string; - range: { start: number; end: number }; - text: string; - marks?: Record; - }): void { - const { blockId, range, text, marks } = options; - const cellCoord = this._getActiveCellCoord(blockId); - const ops: DocumentOp[] = []; - const nextOffset = range.start + text.length; - this.pendingSelectionOverride = { - blockId, - anchorOffset: nextOffset, - focusOffset: nextOffset, - cell: cellCoord - ? { row: cellCoord.row, col: cellCoord.col } - : undefined, - }; - - if (range.end > range.start) { - if (cellCoord) { - ops.push({ - type: "delete-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: range.start, - length: range.end - range.start, - }); - } else { - ops.push({ - type: "delete-text", - blockId, - offset: range.start, - length: range.end - range.start, - }); - } - } - - if (text.length > 0) { - if (cellCoord) { - ops.push({ - type: "insert-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: range.start, - text, - }); - } else { - ops.push({ - type: "insert-text", - blockId, - offset: range.start, - text, - marks, - }); - } - } - - if (ops.length > 0) { - this.editor.apply(ops, { origin: "user" }); - } - - if (cellCoord) { - this.activeCellSelection = { - start: nextOffset, - end: nextOffset, - }; - } else { - this.fieldEditor.syncTextSelection(blockId, nextOffset, nextOffset); - } - this.restoreDOMSelectionFromEditor(); - this.pendingSelectionOverride = null; - } - - applyListInputRule(options: { - blockId: string; - range: { start: number; end: number }; - text: string; - }): boolean { - const target = applyListInputRule(this.editor, options); - if (!target) return false; - - this.pendingSelectionOverride = { - blockId: target.blockId, - anchorOffset: target.anchorOffset, - focusOffset: target.focusOffset, - }; - - this.fieldEditor.syncTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - this.restoreDOMSelectionFromEditor(); - this.pendingSelectionOverride = null; - return true; - } - - restoreDOMSelectionFromEditor(): void { - if (!this.element) return; - - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - const selection = this.editor.selection; - - const pendingSelection = - this.pendingSelectionOverride?.blockId === blockId - ? this.pendingSelectionOverride - : null; - const activeCell = this._getActiveCellCoord(blockId); - if ( - activeCell && - (!pendingSelection || - (pendingSelection.cell?.row === activeCell.row && - pendingSelection.cell?.col === activeCell.col)) - ) { - const activeSelection = - pendingSelection ?? - (this.activeCellSelection - ? { - anchorOffset: this.activeCellSelection.start, - focusOffset: this.activeCellSelection.end, - } - : null) ?? - (selection?.type === "text" && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? { - anchorOffset: selection.anchor.offset, - focusOffset: selection.focus.offset, - } - : null); - if (!activeSelection) return; - const start = activeSelection.anchorOffset; - const end = activeSelection.focusOffset; - this.isApplyingSelection++; - setSelectionOffsets(this.element, start, end); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); - return; - } - const anchor = - pendingSelection != null - ? { - blockId: pendingSelection.blockId, - offset: pendingSelection.anchorOffset, - } - : selection?.type === "text" - ? selection.anchor - : null; - const focus = - pendingSelection != null - ? { - blockId: pendingSelection.blockId, - offset: pendingSelection.focusOffset, - } - : selection?.type === "text" - ? selection.focus - : null; - - if (!anchor || !focus) return; - if (anchor.blockId !== blockId || focus.blockId !== blockId) { - return; - } - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - - this.isApplyingSelection++; - editorSelectionToDOM(root, anchor, focus); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); - } - - // ── Direct input handling ───────────────────────────────── - - private handleBeforeInput = (event: InputEvent): void => { - if (this.isComposing) return; - if (!this.ytext || !this.element) return; - - const blockId = this.fieldEditor.focusBlockId; - if (!blockId || !this.editor.getBlock(blockId)) { - this.fieldEditor.deactivate(); - return; - } - - const handler = DIRECT_HANDLERS[event.inputType]; - if (handler) { - event.preventDefault(); - handler( - event, - this.editor, - this.ytext, - this.fieldEditor, - this.element, - this, - ); - return; - } - - // Let the mutation observer reconcile input types we do not handle directly. - }; - - // ── Composition handling ────────────────────────────────── - - private handleCompositionStart = (): void => { - this.isComposing = true; - this.compositionStartTimestamp = Date.now(); - this.compositionStartText = this.ytext?.toString() ?? ""; - this.deferredRemoteDeltas = []; - this.fieldEditor.setComposing(true); - }; - - private handleCompositionEnd = (): void => { - this.isComposing = false; - this.fieldEditor.setComposing(false); - - const elapsed = Date.now() - this.compositionStartTimestamp; - - // GBoard rapid composition optimization: skip full diff for single-char - // compositions under 50ms — treat as direct insert. - if (elapsed < 50 && this.element) { - const domText = extractTextFromDOM(this.element); - const crdtText = this.ytext?.toString() ?? ""; - if (Math.abs(domText.length - crdtText.length) <= 1) { - this.reconcileAfterComposition(); - return; - } - } - - // Safari may fire compositionend before the final DOM mutation. - requestAnimationFrame(() => { - if (this.isComposing) return; - this.reconcileAfterComposition(); - }); - }; - - private reconcileAfterComposition(): void { - if (!this.element || !this.ytext) return; - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - - const domText = extractTextFromDOM(this.element); - const baseText = this.compositionStartText ?? this.ytext.toString(); - - if (domText !== baseText) { - const diff = rebaseTextDiffOps( - computeTextDiff(baseText, domText), - this.deferredRemoteDeltas, - ); - this.applyTextDiffAsOps(blockId, diff); - } - - if (this.deferredRemoteDeltas.length > 0) { - this.deferredRemoteDeltas = []; - fullReconcileToDOM(this.ytext, this.element!, this.editor.schema, { - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - } - - this.compositionStartText = null; - this.restoreDOMSelectionFromEditor(); - } - - // ── Mutation observation fallback ───────────────────────── - - private handleMutations = (_mutations: MutationRecord[]): void => { - if (this.isComposing) return; - if (!this.element || !this.ytext) return; - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - - const domText = extractTextFromDOM(this.element); - const crdtText = this.ytext.toString(); - - if (domText !== crdtText) { - const diff = computeTextDiff(crdtText, domText); - this.applyTextDiffAsOps(blockId, diff); - } - }; - - // ── CRDT→DOM reconciliation ─────────────────────────────── - - private handleYTextChange = (event: FieldEditorTextChangeEvent): void => { - if (this.isComposing) { - if ( - event.transaction?.origin === "remote" || - event.transaction?.origin === "collaborator" - ) { - this.deferredRemoteDeltas.push({ delta: event.delta }); - } - return; - } - - if (!this.element || !this.ytext) return; - const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); - if (isHistory) { - return; - } - - const blockId = this.fieldEditor.focusBlockId; - const isActiveCell = blockId ? !!this._getActiveCellCoord(blockId) : false; - if (isActiveCell) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - if ( - this.pendingSelectionOverride != null || - event.transaction?.origin === "remote" || - event.transaction?.origin === "collaborator" - ) { - this.restoreDOMSelectionFromEditor(); - } - return; - } - - const applied = applyDeltaToDOM( - event.delta, - this.element, - this.editor.schema, - ); - if (!applied) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - } - - if ( - this.pendingSelectionOverride != null || - event.transaction?.origin === "remote" || - event.transaction?.origin === "collaborator" - ) { - this.restoreDOMSelectionFromEditor(); - } - }; - - private applyTextDiffAsOps( - blockId: string, - diff: Array< - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number } - >, - ): void { - if (diff.length === 0) return; - const ytext = this.ytext; - if (!ytext) return; - - const ops: DocumentOp[] = []; - const cellCoord = this._getActiveCellCoord(blockId); - for (const op of diff) { - if (op.type === "delete") { - if (cellCoord) { - ops.push({ - type: "delete-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: op.offset, - length: op.length, - }); - } else { - ops.push({ - type: "delete-text", - blockId, - offset: op.offset, - length: op.length, - }); - } - continue; - } - - if (cellCoord) { - ops.push({ - type: "insert-table-cell-text", - blockId, - row: cellCoord.row, - col: cellCoord.col, - offset: op.offset, - text: op.text, - }); - } else { - ops.push({ - type: "insert-text", - blockId, - offset: op.offset, - text: op.text, - marks: this.fieldEditor.resolveInsertMarks(ytext, op.offset), - }); - } - } - - if (ops.length === 0) return; - - const range = this.element ? getSelectionOffsets(this.element) : null; - if (range) { - this.pendingSelectionOverride = { - blockId, - anchorOffset: range.start, - focusOffset: range.end, - cell: cellCoord ? { row: cellCoord.row, col: cellCoord.col } : undefined, - }; - } - - this.editor.apply(ops, { origin: "user" }); - - if (range) { - if (cellCoord) { - this.activeCellSelection = range; - } else { - this.fieldEditor.syncTextSelection(blockId, range.start, range.end); - } - } - this.pendingSelectionOverride = null; - } - - private getInlineDecorationsForBlock(): readonly InlineDecoration[] { - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) { - return []; - } - return this.editor - .getDecorations() - .forBlock(blockId) - .filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", - ); - } - - // ── Keyboard shortcuts ──────────────────────────────────── - - private handleKeyDown = (event: KeyboardEvent): void => { - if (!this.ytext) return; - - const handled = handleFieldEditorKeyDown({ - event, - editor: this.editor, - fieldEditor: this.fieldEditor, - ytext: this.ytext, - range: this.element ? getSelectionOffsets(this.element) : null, - }); - if (handled) { - event.preventDefault(); - return; - } - }; - - private handleSelectionChange = (): void => { - if (!this.element) return; - if (!this.fieldEditor.shouldHandleDomSelectionChange(this.isApplyingSelection)) { - return; - } - - const focusBlockId = this.fieldEditor.focusBlockId; - const activeCell = focusBlockId - ? this._getActiveCellCoord(focusBlockId) - : null; - if (activeCell) { - const range = getSelectionOffsets(this.element); - if (!range) return; - this.activeCellSelection = range; - return; - } - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - - const selection = domSelectionToEditor(root); - if (!selection) return; - const normalizedSelection = normalizeSelectionFormation( - this.editor, - selection, - ); - - if (normalizedSelection.type === "block") { - this.fieldEditor.deactivate(); - this.editor.setSelection({ - type: "block", - blockIds: normalizedSelection.blockIds, - }); - return; - } - - this.fieldEditor.applyDomTextSelection( - normalizedSelection.anchor, - normalizedSelection.focus, - ); - }; - - // ── Clipboard events ────────────────────────────────────── - - private handleCopyEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCopy(this.editor, event); - }; - - private handleCutEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCut(this.editor, event); - }; - - private handleDragStart = (event: DragEvent): void => { - event.preventDefault(); - }; - - private handleDrop = (event: DragEvent): void => { - event.preventDefault(); - }; -} - -// ── Direct input handlers ────────────────────────────────── - -type DirectHandler = ( - event: InputEvent, - editor: Editor, - ytext: FieldEditorTextLike, - fieldEditor: FieldEditorInputController, - element: HTMLElement, - backend: ContentEditableBackend, -) => void; - -const DIRECT_HANDLERS: Record = { - insertText: (event, editor, ytext, fe, element, backend) => { - const text = event.data ?? ""; - if (!text) return; - if (hasMultiBlockTextSelection(editor)) { - editor.replaceSelection(text); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const range = getSelectionOffsets(element); - if (!range) return; - if (backend.applyListInputRule({ blockId, range, text })) { - return; - } - const marks = fe.resolveInsertMarks(ytext, range.start); - backend.applyInlineTextEdit({ - blockId, - range, - text, - marks, - }); - }, - - insertReplacementText: (event, editor, ytext, fe, element, backend) => { - const text = event.data ?? ""; - if (!text) return; - if (hasMultiBlockTextSelection(editor)) { - editor.replaceSelection(text); - return; - } - const blockId = fe.focusBlockId; - if (!blockId) return; - const targetRanges = event.getTargetRanges?.(); - const range = targetRanges?.length - ? staticRangeToOffsets(targetRanges[0], element) - : getSelectionOffsets(element); - if (!range) return; - if (backend.applyListInputRule({ blockId, range, text })) { - return; - } - const marks = fe.resolveInsertMarks(ytext, range.start); - backend.applyInlineTextEdit({ - blockId, - range, - text, - marks, - }); - }, - - deleteContentBackward: (_event, editor, ytext, fe, element, backend) => { - if (hasMultiBlockTextSelection(editor)) { - editor.deleteSelection(); - return; - } - const range = getSelectionOffsets(element); - if (!range) return; - - const target = applyDeleteBehavior(editor, { - blockId: fe.focusBlockId ?? "", - ytext, - range, - direction: "backward", - }); - if (target) { - if (target.selectBlock) { - fe.deactivate(); - editor.selectBlock(target.blockId); - } else { - fe.activateTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - } - return; - } - - if (range.start !== range.end) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range, - text: "", - }); - return; - } - - if (range.start > 0) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range: { start: range.start - 1, end: range.start }, - text: "", - }); - } - }, - - deleteContentForward: (_event, editor, ytext, fe, element, backend) => { - if (hasMultiBlockTextSelection(editor)) { - editor.deleteSelection(); - return; - } - const range = getSelectionOffsets(element); - if (!range) return; - - const target = applyDeleteBehavior(editor, { - blockId: fe.focusBlockId ?? "", - ytext, - range, - direction: "forward", - }); - if (target) { - if (target.selectBlock) { - fe.deactivate(); - editor.selectBlock(target.blockId); - } else { - fe.activateTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - } - return; - } - - if (range.start < ytext.length) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range: { start: range.start, end: range.start + 1 }, - text: "", - }); - } - }, - - deleteByCut: (_event, editor, _ytext, fe, element, backend) => { - if (hasMultiBlockTextSelection(editor)) { - editor.deleteSelection(); - return; - } - const range = getSelectionOffsets(element); - if (!range || range.start === range.end) return; - - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range, - text: "", - }); - }, - - deleteWordBackward: (_event, editor, ytext, fe, element, backend) => { - const range = getSelectionOffsets(element); - if (!range) return; - - if (range.start !== range.end) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range, - text: "", - }); - return; - } - - const text = ytext.toString(); - let pos = range.start; - while (pos > 0 && /\s/.test(text[pos - 1])) pos--; - while (pos > 0 && !/\s/.test(text[pos - 1])) pos--; - if (pos < range.start) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range: { start: pos, end: range.start }, - text: "", - }); - } - }, - - deleteWordForward: (_event, editor, ytext, fe, element, backend) => { - const range = getSelectionOffsets(element); - if (!range) return; - - if (range.start !== range.end) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range, - text: "", - }); - return; - } - - const text = ytext.toString(); - let pos = range.end; - while (pos < text.length && /\s/.test(text[pos])) pos++; - while (pos < text.length && !/\s/.test(text[pos])) pos++; - if (pos > range.end) { - backend.applyInlineTextEdit({ - blockId: fe.focusBlockId ?? "", - range: { start: range.end, end: pos }, - text: "", - }); - } - }, - - insertParagraph: (_event, editor, ytext, fe, element) => { - const blockId = fe.focusBlockId; - if (!blockId) return; - const target = applyEnterBehavior(editor, { - blockId, - inputMode: fe.inputMode, - ytext, - range: getSelectionOffsets(element), - }); - if (!target) return; - - fe.activateTextSelection( - target.blockId, - target.anchorOffset, - target.focusOffset, - ); - }, - - insertLineBreak: (_event, _editor, ytext, fe, element, backend) => { - const range = getSelectionOffsets(element); - if (!range) return; - const blockId = fe.focusBlockId; - if (!blockId) return; - backend.applyInlineTextEdit({ - blockId, - range, - text: "\n", - marks: fe.resolveInsertMarks(ytext, range.start), - }); - }, - - historyUndo: (_event, editor) => { - editor.undoManager.undo(); - }, - - historyRedo: (_event, editor) => { - editor.undoManager.redo(); - }, - - insertFromPaste: (event, editor, _ytext, fe) => { - const importers = - editor.internals.getSlot("paste:importers"); - handlePaste(event, editor, fe, importers ?? undefined); - }, - - formatBold: (_event, editor) => { - toggleInlineMark(editor, "bold"); - }, - - formatItalic: (_event, editor) => { - toggleInlineMark(editor, "italic"); - }, - - formatUnderline: (_event, editor) => { - toggleInlineMark(editor, "underline"); - }, - - formatStrikeThrough: (_event, editor) => { - toggleInlineMark(editor, "strikethrough"); - }, -}; - -function hasMultiBlockTextSelection(editor: Editor): boolean { - const selection = editor.selection; - return selection?.type === "text" && selection.isMultiBlock; -} - -/** - * Convert a StaticRange (from getTargetRanges) to character offsets - * within the inline content element. - */ -function staticRangeToOffsets( - staticRange: StaticRange, - element: HTMLElement, -): { start: number; end: number } | null { - if ( - (staticRange.startContainer !== element && - !element.contains(staticRange.startContainer)) || - (staticRange.endContainer !== element && - !element.contains(staticRange.endContainer)) - ) { - return null; - } - - const startOffset = domPointToOffset( - element, - staticRange.startContainer, - staticRange.startOffset, - ); - const endOffset = domPointToOffset( - element, - staticRange.endContainer, - staticRange.endOffset, - ); - - return { - start: Math.min(startOffset, endOffset), - end: Math.max(startOffset, endOffset), - }; -} - -function setSelectionOffsets( - element: HTMLElement, - startOffset: number, - endOffset: number, -): void { - const selection = element.ownerDocument?.getSelection(); - if (!selection) return; - - const startPoint = resolveDomPointForOffset(element, startOffset); - const endPoint = resolveDomPointForOffset(element, endOffset); - if (!startPoint || !endPoint) return; - - selection.removeAllRanges(); - - const setBaseAndExtent = ( - selection as Selection & { - setBaseAndExtent?: ( - anchorNode: Node, - anchorOffset: number, - focusNode: Node, - focusOffset: number, - ) => void; - } - ).setBaseAndExtent; - if (typeof setBaseAndExtent === "function") { - try { - setBaseAndExtent.call( - selection, - startPoint.node, - startPoint.offset, - endPoint.node, - endPoint.offset, - ); - return; - } catch { - // Fall back to the range-based path in non-browser test environments. - } - } - - const collapseRange = element.ownerDocument.createRange(); - collapseRange.setStart(startPoint.node, startPoint.offset); - collapseRange.collapse(true); - selection.addRange(collapseRange); - - if ( - (startPoint.node !== endPoint.node || startPoint.offset !== endPoint.offset) && - typeof selection.extend === "function" - ) { - selection.extend(endPoint.node, endPoint.offset); - return; - } - - selection.removeAllRanges(); - const range = element.ownerDocument.createRange(); - range.setStart(startPoint.node, startPoint.offset); - range.setEnd(endPoint.node, endPoint.offset); - selection.addRange(range); -} - -function resolveDomPointForOffset( - element: HTMLElement, - targetOffset: number, -): { node: Node; offset: number } | null { - const walker = element.ownerDocument.createTreeWalker( - element, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = Math.max(0, targetOffset); - let textNode = walker.nextNode() as Text | null; - - while (textNode) { - const length = textNode.textContent?.length ?? 0; - if (remaining <= length) { - return { node: textNode, offset: remaining }; - } - remaining -= length; - textNode = walker.nextNode() as Text | null; - } - - if (element.lastChild) { - if (element.lastChild.nodeType === Node.TEXT_NODE) { - const textLength = element.lastChild.textContent?.length ?? 0; - return { - node: element.lastChild, - offset: textLength, - }; - } - const childCount = element.lastChild.childNodes.length; - return { node: element.lastChild, offset: childCount }; - } - - return { node: element, offset: 0 }; -} - -function rebaseTextDiffOps( - ops: Array< - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number } - >, - deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, -): Array< - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number } -> { - if (deferredRemoteDeltas.length === 0 || ops.length === 0) { - return ops; - } - - return ops - .map((op) => { - if (op.type === "insert") { - return { - type: "insert" as const, - offset: mapOffsetThroughRemoteDeltas( - op.offset, - deferredRemoteDeltas, - ), - text: op.text, - }; - } - - const start = mapOffsetThroughRemoteDeltas( - op.offset, - deferredRemoteDeltas, - ); - const end = mapOffsetThroughRemoteDeltas( - op.offset + op.length, - deferredRemoteDeltas, - ); - return { - type: "delete" as const, - offset: start, - length: Math.max(0, end - start), - }; - }) - .filter((op) => { - if (op.type === "insert") { - return true; - } - return op.length > 0; - }); -} - -function mapOffsetThroughRemoteDeltas( - originalOffset: number, - deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, -): number { - let mappedOffset = originalOffset; - - for (const { delta } of deferredRemoteDeltas) { - let cursor = 0; - for (const part of delta) { - if (part.retain != null) { - cursor += part.retain; - continue; - } - - if (part.delete != null) { - if (cursor < mappedOffset) { - const deletedBeforeOffset = Math.min( - part.delete, - mappedOffset - cursor, - ); - mappedOffset -= deletedBeforeOffset; - } - continue; - } - - if (part.insert != null) { - const insertedLength = - typeof part.insert === "string" ? part.insert.length : 1; - if (cursor <= mappedOffset) { - mappedOffset += insertedLength; - } - cursor += insertedLength; - } - } - } - - return mappedOffset; -} +import { ContentEditableBackendSelection } from "./contenteditableBackendSelection"; +export class ContentEditableBackend extends ContentEditableBackendSelection {} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts b/packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts new file mode 100644 index 0000000..023856a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableBackendCore.ts @@ -0,0 +1,328 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + computeTextDiff, + domPointToOffset, + domSelectionToEditor, + editorSelectionToDOM, + extractTextFromDOM, + getSelectionOffsets, +} from "./selectionBridge"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import type { PasteImporters } from "../types/paste"; +import { handlePaste, handleCopy, handleCut } from "./clipboard"; +import { + applyListInputRule, + applyDeleteBehavior, + applyEnterBehavior, + toggleInlineMark, +} from "./commands"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import type { InlineTextDiffOp } from "./inlineTextTransaction"; +import { + applyInlineTextDiffInput, + applyInlineTextInput, +} from "./textInputPipeline"; +import type { + FieldEditorDelta, + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { setSelectionOffsets } from "./contenteditableDomHelpers"; + +export abstract class ContentEditableBackendCore { + protected element: HTMLElement | null = null; + protected ytext: FieldEditorTextLike | null = null; + protected observer: FieldEditorObserver | null = null; + protected mutationObserver: MutationObserver | null = null; + protected isComposing = false; + protected compositionStartTimestamp = 0; + protected compositionStartText: string | null = null; + protected deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }> = []; + protected pendingDomSyncFrame: number | null = null; + protected unsubscribeDecorationsChange: (() => void) | null = null; + protected inlineDecorationsSignature: string | null = null; + protected editor: Editor; + protected fieldEditor: FieldEditorInputController; + + constructor(editor: Editor, fieldEditor: FieldEditorInputController) { + this.editor = editor; + this.fieldEditor = fieldEditor; + } + + activate(element: HTMLElement, ytext: unknown): void { + this.element = element; + this.ytext = ytext as FieldEditorTextLike; + + element.contentEditable = "true"; + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + this.isComposing = false; + this.compositionStartText = null; + this.fieldEditor.setComposing(false); + + element.addEventListener("beforeinput", this.handleBeforeInput); + element.addEventListener( + "compositionstart", + this.handleCompositionStart, + ); + element.addEventListener("compositionend", this.handleCompositionEnd); + element.addEventListener("keydown", this.handleKeyDown); + element.addEventListener("copy", this.handleCopyEvent); + element.addEventListener("cut", this.handleCutEvent); + element.addEventListener("dragstart", this.handleDragStart); + element.addEventListener("drop", this.handleDrop); + element.addEventListener("pointerdown", this.handlePointerDown); + element.ownerDocument?.addEventListener( + "selectionchange", + this.handleSelectionChange, + ); + + this.mutationObserver = new MutationObserver(this.handleMutations); + this.mutationObserver.observe(element, { + childList: true, + subtree: true, + characterData: true, + characterDataOldValue: true, + }); + + this.observer = (event) => this.handleYTextChange(event); + this.ytext.observe(this.observer); + this.unsubscribeDecorationsChange = this.editor.on( + "decorationsChange", + this.handleDecorationsChange, + ); + this.inlineDecorationsSignature = this.getInlineDecorationsSignature(); + + fullReconcileToDOM(this.ytext, element, this.editor.schema, { + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.restoreDOMSelectionFromEditor(); + } + + deactivate(): void { + if (this.element) { + this.element.contentEditable = "false"; + this.element.removeEventListener( + "beforeinput", + this.handleBeforeInput, + ); + this.element.removeEventListener( + "compositionstart", + this.handleCompositionStart, + ); + this.element.removeEventListener( + "compositionend", + this.handleCompositionEnd, + ); + this.element.removeEventListener("keydown", this.handleKeyDown); + this.element.removeEventListener("copy", this.handleCopyEvent); + this.element.removeEventListener("cut", this.handleCutEvent); + this.element.removeEventListener("dragstart", this.handleDragStart); + this.element.removeEventListener("drop", this.handleDrop); + this.element.removeEventListener( + "pointerdown", + this.handlePointerDown, + ); + this.element.ownerDocument?.removeEventListener( + "selectionchange", + this.handleSelectionChange, + ); + } + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + this.mutationObserver = null; + } + if (this.pendingDomSyncFrame != null) { + cancelAnimationFrame(this.pendingDomSyncFrame); + this.pendingDomSyncFrame = null; + } + if (this.observer && this.ytext) { + this.ytext.unobserve(this.observer); + } + this.unsubscribeDecorationsChange?.(); + this.unsubscribeDecorationsChange = null; + this.element = null; + this.ytext = null; + this.observer = null; + this.inlineDecorationsSignature = null; + this.deferredRemoteDeltas = []; + this.fieldEditor.resetBackendSelectionAuthority(); + this.isComposing = false; + this.compositionStartText = null; + this.fieldEditor.setComposing(false); + } + + updateSelection(_relPos: unknown): void { + this.restoreDOMSelectionFromEditor(); + } + + protected _getActiveCellCoord(blockId: string): { + blockId: string; + row: number; + col: number; + } | null { + const coord = this.fieldEditor.activeCellCoord; + if (!coord || coord.blockId !== blockId) { + return null; + } + return coord; + } + + applyInlineTextEdit(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + marks?: Record; + }): void { + const { blockId, range, text, marks } = options; + const cellCoord = this._getActiveCellCoord(blockId); + applyInlineTextInput({ + editor: this.editor, + fieldEditor: this.fieldEditor, + blockId, + range, + text, + marks, + cellCoord, + }); + this.ensureActiveDOMMatchesYText(); + this.restoreDOMSelectionFromEditor(); + this.scheduleActiveDOMMatchCheck(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } + + applyListInputRule(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + }): boolean { + const target = applyListInputRule(this.editor, options); + if (!target) return false; + + this.fieldEditor.setBackendSelectionAuthority("programmatic", { + blockId: target.blockId, + anchorOffset: target.anchorOffset, + focusOffset: target.focusOffset, + }); + + this.fieldEditor.syncTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + this.restoreDOMSelectionFromEditor(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + return true; + } + + restoreDOMSelectionFromEditor(): void { + if (!this.element) return; + + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + const selection = this.editor.selection; + + const pendingSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + blockId, + ); + const activeCell = this._getActiveCellCoord(blockId); + if ( + activeCell && + (!pendingSelection || + (pendingSelection.cell?.row === activeCell.row && + pendingSelection.cell?.col === activeCell.col)) + ) { + const activeSelection = + pendingSelection ?? + this.fieldEditor.getBackendSelectionAuthority("cell", blockId) ?? + (selection?.type === "text" && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ? { + anchorOffset: selection.anchor.offset, + focusOffset: selection.focus.offset, + } + : null); + if (!activeSelection) return; + const start = activeSelection.anchorOffset; + const end = activeSelection.focusOffset; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + setSelectionOffsets(this.element, start, end); + return; + } + const anchor = + pendingSelection != null + ? { + blockId: pendingSelection.blockId, + offset: pendingSelection.anchorOffset, + } + : selection?.type === "text" + ? selection.anchor + : null; + const focus = + pendingSelection != null + ? { + blockId: pendingSelection.blockId, + offset: pendingSelection.focusOffset, + } + : selection?.type === "text" + ? selection.focus + : null; + + if (!anchor || !focus) return; + if (anchor.blockId !== blockId || focus.blockId !== blockId) { + return; + } + if ( + pendingSelection == null && + anchor.offset === focus.offset && + selection?.type === "text" && + selection.isCollapsed + ) { + this.fieldEditor.setBackendSelectionAuthority("programmatic", { + blockId, + anchorOffset: anchor.offset, + focusOffset: focus.offset, + }); + } + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + editorSelectionToDOM(root, anchor, focus); + } + + protected abstract handleBeforeInput: (event: InputEvent) => void; + protected abstract handleCompositionStart: () => void; + protected abstract handleCompositionEnd: () => void; + protected abstract handleKeyDown: (event: KeyboardEvent) => void; + protected abstract handleCopyEvent: (event: ClipboardEvent) => void; + protected abstract handleCutEvent: (event: ClipboardEvent) => void; + protected abstract handleDragStart: (event: DragEvent) => void; + protected abstract handleDrop: (event: DragEvent) => void; + protected abstract handlePointerDown: () => void; + protected abstract handleSelectionChange: () => void; + protected abstract handleMutations: (mutations: MutationRecord[]) => void; + protected abstract handleYTextChange(event: FieldEditorTextChangeEvent): void; + protected abstract handleDecorationsChange: () => void; + abstract resolveCurrentInputRange(): { start: number; end: number } | null; + protected abstract applyTextDiffAsOps( + blockId: string, + diff: InlineTextDiffOp[], + ): void; + protected abstract ensureActiveDOMMatchesYText(): boolean; + protected abstract scheduleActiveDOMMatchCheck(): void; + protected abstract getInlineDecorationsForBlock(): readonly InlineDecoration[]; + protected abstract getInlineDecorationsSignature(): string; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts b/packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts new file mode 100644 index 0000000..31463be --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableBackendEvents.ts @@ -0,0 +1,230 @@ +import type { InlineDecoration } from "@pen/types"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { computeTextDiff, extractTextFromDOM } from "./selectionBridge"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import type { FieldEditorTextChangeEvent } from "./crdt"; +import type { InlineTextDiffOp } from "./inlineTextTransaction"; +import { ContentEditableBackendCore } from "./contenteditableBackendCore"; +import { DIRECT_HANDLERS } from "./contenteditableDirectHandlers"; +import { + canResolveInputRange, + rebaseTextDiffOps, + requiresResolvedInputRange, +} from "./contenteditableDomHelpers"; +import { inlineDecorationsRequireFullReconcile } from "../utils/inlineDecorations"; + +export abstract class ContentEditableBackendEvents extends ContentEditableBackendCore { + protected handleBeforeInput = (event: InputEvent): void => { + if (this.isComposing) return; + if (!this.ytext || !this.element) return; + + const blockId = this.fieldEditor.focusBlockId; + if (!blockId || !this.editor.getBlock(blockId)) { + this.fieldEditor.deactivate(); + return; + } + + const handler = DIRECT_HANDLERS[event.inputType]; + if (handler) { + if ( + requiresResolvedInputRange(event.inputType) && + !this.ensureResolvableInputRange(event) + ) { + return; + } + + event.preventDefault(); + handler( + event, + this.editor, + this.ytext, + this.fieldEditor, + this.element, + this, + ); + return; + } + + // Let the mutation observer reconcile input types we do not handle directly. + }; + + protected ensureResolvableInputRange(event: InputEvent): boolean { + if (!this.element) { + return false; + } + if (canResolveInputRange(event, this.element)) { + return true; + } + + this.restoreDOMSelectionFromEditor(); + + return canResolveInputRange(event, this.element); + } + + // ── Composition handling ────────────────────────────────── + + protected handleCompositionStart = (): void => { + this.isComposing = true; + this.compositionStartTimestamp = Date.now(); + this.compositionStartText = this.ytext?.toString() ?? ""; + this.deferredRemoteDeltas = []; + this.fieldEditor.setComposing(true); + }; + + protected handleCompositionEnd = (): void => { + this.isComposing = false; + this.fieldEditor.setComposing(false); + + const elapsed = Date.now() - this.compositionStartTimestamp; + + // GBoard rapid composition optimization: skip full diff for single-char + // compositions under 50ms — treat as direct insert. + if (elapsed < 50 && this.element) { + const domText = extractTextFromDOM(this.element); + const crdtText = this.ytext?.toString() ?? ""; + if (Math.abs(domText.length - crdtText.length) <= 1) { + this.reconcileAfterComposition(); + return; + } + } + + // Safari may fire compositionend before the final DOM mutation. + requestAnimationFrame(() => { + if (this.isComposing) return; + this.reconcileAfterComposition(); + }); + }; + + protected reconcileAfterComposition(): void { + if (!this.element || !this.ytext) return; + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + + const domText = extractTextFromDOM(this.element); + const baseText = this.compositionStartText ?? this.ytext.toString(); + + if (domText !== baseText) { + const diff = rebaseTextDiffOps( + computeTextDiff(baseText, domText), + this.deferredRemoteDeltas, + ); + this.applyTextDiffAsOps(blockId, diff); + } + + if (this.deferredRemoteDeltas.length > 0) { + this.deferredRemoteDeltas = []; + fullReconcileToDOM(this.ytext, this.element!, this.editor.schema, { + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + } + + this.compositionStartText = null; + this.restoreDOMSelectionFromEditor(); + } + + // ── Mutation observation fallback ───────────────────────── + + protected handleMutations = (_mutations: MutationRecord[]): void => { + if (this.isComposing) return; + if (!this.element || !this.ytext) return; + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + + const domText = extractTextFromDOM(this.element); + const crdtText = this.ytext.toString(); + + if (domText !== crdtText) { + const diff = computeTextDiff(crdtText, domText); + this.applyTextDiffAsOps(blockId, diff); + } + }; + + // ── CRDT→DOM reconciliation ─────────────────────────────── + + protected handleYTextChange = (event: FieldEditorTextChangeEvent): void => { + if (this.isComposing) { + if ( + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.deferredRemoteDeltas.push({ delta: event.delta }); + } + return; + } + + if (!this.element || !this.ytext) return; + const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); + if (isHistory) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.restoreDOMSelectionFromEditor(); + return; + } + + const blockId = this.fieldEditor.focusBlockId; + const isActiveCell = blockId + ? !!this._getActiveCellCoord(blockId) + : false; + if (isActiveCell) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + if ( + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.restoreDOMSelectionFromEditor(); + } + return; + } + + const inlineDecorations = this.getInlineDecorationsForBlock(); + if (inlineDecorationsRequireFullReconcile(inlineDecorations)) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations, + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + if ( + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.restoreDOMSelectionFromEditor(); + } + return; + } + + const applied = applyDeltaToDOM( + event.delta, + this.element, + this.editor.schema, + ); + if (!applied) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + } + + if ( + this.fieldEditor.hasBackendSelectionAuthority("programmatic") || + event.transaction?.origin === "remote" || + event.transaction?.origin === "collaborator" + ) { + this.restoreDOMSelectionFromEditor(); + } + }; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts b/packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts new file mode 100644 index 0000000..994a368 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableBackendSelection.ts @@ -0,0 +1,360 @@ +import type { InlineDecoration } from "@pen/types"; +import { buildInlineDecorationsRenderSignature } from "../utils/inlineDecorations"; +import { fullReconcileToDOM } from "./reconciler"; +import { + domSelectionToEditor, + extractTextFromDOM, + getSelectionOffsets, +} from "./selectionBridge"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleCopy, handleCut } from "./clipboard"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import type { InlineTextDiffOp } from "./inlineTextTransaction"; +import { applyInlineTextDiffInput } from "./textInputPipeline"; +import { ContentEditableBackendEvents } from "./contenteditableBackendEvents"; +import { + isNavigationSelectionKey, + setSelectionOffsets, +} from "./contenteditableDomHelpers"; + +export class ContentEditableBackendSelection extends ContentEditableBackendEvents { + protected applyTextDiffAsOps( + blockId: string, + diff: InlineTextDiffOp[], + ): void { + if (diff.length === 0) return; + const ytext = this.ytext; + if (!ytext) return; + + const cellCoord = this._getActiveCellCoord(blockId); + const range = this.element ? getSelectionOffsets(this.element) : null; + const selection = range + ? { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + cell: cellCoord + ? { row: cellCoord.row, col: cellCoord.col } + : undefined, + } + : null; + const result = applyInlineTextDiffInput({ + editor: this.editor, + fieldEditor: this.fieldEditor, + blockId, + diff, + ytext, + selection, + cellCoord, + }); + if (!result.applied) return; + this.ensureActiveDOMMatchesYText(); + this.restoreDOMSelectionFromEditor(); + this.scheduleActiveDOMMatchCheck(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } + + protected ensureActiveDOMMatchesYText(): boolean { + if (!this.element || !this.ytext) return false; + const nextInlineDecorationsSignature = this.getInlineDecorationsSignature(); + if ( + extractTextFromDOM(this.element) === this.ytext.toString() && + nextInlineDecorationsSignature === this.inlineDecorationsSignature + ) { + return false; + } + + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.inlineDecorationsSignature = nextInlineDecorationsSignature; + return true; + } + + protected handleDecorationsChange = (): void => { + if (this.isComposing) { + return; + } + if (this.getInlineDecorationsSignature() === this.inlineDecorationsSignature) { + return; + } + this.scheduleActiveDOMMatchCheck(); + }; + + protected scheduleActiveDOMMatchCheck(): void { + if (this.pendingDomSyncFrame != null) { + cancelAnimationFrame(this.pendingDomSyncFrame); + } + + this.pendingDomSyncFrame = requestAnimationFrame(() => { + this.pendingDomSyncFrame = null; + if (this.ensureActiveDOMMatchesYText()) { + this.restoreDOMSelectionFromEditor(); + } + }); + } + + protected getInlineDecorationsForBlock(): readonly InlineDecoration[] { + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) { + return []; + } + return this.editor + .getDecorations() + .forBlock(blockId) + .filter( + (decoration): decoration is InlineDecoration => + decoration.type === "inline", + ); + } + + protected getInlineDecorationsSignature(): string { + return buildInlineDecorationsRenderSignature( + this.getInlineDecorationsForBlock(), + ); + } + + // ── Keyboard shortcuts ──────────────────────────────────── + + protected handleKeyDown = (event: KeyboardEvent): void => { + if (!this.ytext) return; + if (isNavigationSelectionKey(event)) { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + this.fieldEditor.clearBackendSelectionAuthority("user-dom"); + } + + const handled = handleFieldEditorKeyDown({ + event, + editor: this.editor, + fieldEditor: this.fieldEditor, + ytext: this.ytext, + range: this.element ? getSelectionOffsets(this.element) : null, + }); + if (handled) { + event.preventDefault(); + return; + } + }; + + resolveCurrentInputRange(): { + start: number; + end: number; + } | null { + const blockId = this.fieldEditor.focusBlockId; + const liveRange = this.element + ? getSelectionOffsets(this.element) + : null; + return ( + this.fieldEditor.resolveProgrammaticInputRange( + blockId, + liveRange, + ) ?? + liveRange + ); + } + + protected handleSelectionChange = (): void => { + if (!this.element) return; + const isApplyingSelection = + this.fieldEditor.getBackendSelectionApplicationDepth(); + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + isApplyingSelection, + ) + ) { + if (this.shouldRestoreSuppressedFullBlockSelection()) { + this.restoreDOMSelectionFromEditor(); + } + return; + } + + const focusBlockId = this.fieldEditor.focusBlockId; + const activeCell = focusBlockId + ? this._getActiveCellCoord(focusBlockId) + : null; + if (activeCell) { + const range = getSelectionOffsets(this.element); + if (!range) return; + this.fieldEditor.setBackendSelectionAuthority("cell", { + blockId: activeCell.blockId, + anchorOffset: range.start, + focusOffset: range.end, + cell: { row: activeCell.row, col: activeCell.col }, + }); + return; + } + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + + const selection = domSelectionToEditor(root); + if (!selection) return; + const normalizedSelection = normalizeSelectionFormation( + this.editor, + selection, + ); + + if (this.shouldRestoreStaleFullBlockSelection(normalizedSelection)) { + this.restoreDOMSelectionFromEditor(); + return; + } + + if (this.shouldRestoreStaleProjectedSelection(normalizedSelection)) { + this.restoreDOMSelectionFromEditor(); + return; + } + + if (normalizedSelection.type === "block") { + this.fieldEditor.deactivate(); + this.editor.setSelection({ + type: "block", + blockIds: normalizedSelection.blockIds, + }); + return; + } + + if ( + this.fieldEditor.shouldIgnoreDomTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ) + ) { + this.restoreDOMSelectionFromEditor(); + return; + } + + this.fieldEditor.setBackendSelectionAuthority("user-dom", { + blockId: normalizedSelection.anchor.blockId, + anchorOffset: normalizedSelection.anchor.offset, + focusOffset: normalizedSelection.focus.offset, + }); + const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + normalizedSelection.anchor.blockId, + ); + if ( + !projectedSelection || + projectedSelection.anchorOffset !== normalizedSelection.anchor.offset || + projectedSelection.focusOffset !== normalizedSelection.focus.offset + ) { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + } + this.fieldEditor.applyDomTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ); + }; + + protected shouldRestoreStaleFullBlockSelection( + selection: ReturnType, + ): boolean { + if (selection.type === "block") { + return false; + } + if (selection.anchor.blockId !== selection.focus.blockId) { + return false; + } + + const currentSelection = this.fieldEditor.selection; + if ( + currentSelection?.type !== "text" || + !currentSelection.isCollapsed || + currentSelection.focus.blockId !== selection.anchor.blockId + ) { + return false; + } + + const block = this.editor.getBlock(selection.anchor.blockId); + const blockLength = block?.length() ?? null; + if (blockLength == null) { + return false; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + return selectionStart === 0 && selectionEnd === blockLength; + } + + protected shouldRestoreStaleProjectedSelection( + selection: ReturnType, + ): boolean { + if ( + selection.type === "block" || + selection.anchor.blockId !== selection.focus.blockId || + selection.anchor.offset !== selection.focus.offset + ) { + return false; + } + const projectedSelection = this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + selection.anchor.blockId, + ) ?? this.fieldEditor.getBackendSelectionAuthority( + "user-dom", + selection.anchor.blockId, + ); + if (!projectedSelection) { + return false; + } + return ( + selection.anchor.offset !== projectedSelection.anchorOffset || + selection.focus.offset !== projectedSelection.focusOffset + ); + } + + protected shouldRestoreSuppressedFullBlockSelection(): boolean { + if (!this.element) { + return false; + } + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) { + return false; + } + + const selection = domSelectionToEditor(root); + if (!selection) { + return false; + } + + return this.shouldRestoreStaleFullBlockSelection( + normalizeSelectionFormation(this.editor, selection), + ); + } + + // ── Clipboard events ────────────────────────────────────── + + protected handleCopyEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCopy(this.editor, event); + }; + + protected handleCutEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCut(this.editor, event); + }; + + protected handleDragStart = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handleDrop = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handlePointerDown = (): void => { + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + }; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts b/packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts new file mode 100644 index 0000000..1bd2514 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableDirectHandlers.ts @@ -0,0 +1,312 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import type { PasteImporters } from "../types/paste"; +import { + applyDeleteBehavior, + applyEnterBehavior, + toggleInlineMark, +} from "./commands"; +import { handlePaste } from "./clipboard"; +import { staticRangeToOffsets } from "./contenteditableDomHelpers"; + +export interface ContentEditableDirectInputBackend { + resolveCurrentInputRange(): { start: number; end: number } | null; + applyListInputRule(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + }): boolean; + applyInlineTextEdit(options: { + blockId: string; + range: { start: number; end: number }; + text: string; + marks?: Record; + }): void; +} + +export type DirectHandler = ( + event: InputEvent, + editor: Editor, + ytext: FieldEditorTextLike, + fieldEditor: FieldEditorInputController, + element: HTMLElement, + backend: ContentEditableDirectInputBackend, +) => void; + +export const DIRECT_HANDLERS: Record = { + insertText: (event, editor, ytext, fe, element, backend) => { + const text = event.data ?? ""; + if (!text) return; + if (hasMultiBlockTextSelection(editor)) { + editor.replaceSelection(text); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + if (backend.applyListInputRule({ blockId, range, text })) { + return; + } + const marks = fe.resolveInsertMarks(ytext, range.start); + backend.applyInlineTextEdit({ + blockId, + range, + text, + marks, + }); + }, + + insertReplacementText: (event, editor, ytext, fe, element, backend) => { + const text = event.data ?? ""; + if (!text) return; + if (hasMultiBlockTextSelection(editor)) { + editor.replaceSelection(text); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const targetRanges = event.getTargetRanges?.(); + const range = targetRanges?.length + ? staticRangeToOffsets(targetRanges[0], element) + : backend.resolveCurrentInputRange(); + if (!range) return; + if (backend.applyListInputRule({ blockId, range, text })) { + return; + } + const marks = fe.resolveInsertMarks(ytext, range.start); + backend.applyInlineTextEdit({ + blockId, + range, + text, + marks, + }); + }, + + deleteContentBackward: (_event, editor, ytext, fe, element, backend) => { + if (hasMultiBlockTextSelection(editor)) { + editor.deleteSelection(); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + const target = applyDeleteBehavior(editor, { + blockId, + ytext, + range, + direction: "backward", + }); + if (target) { + if (target.selectBlock) { + fe.deactivate(); + editor.selectBlock(target.blockId); + } else { + fe.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + } + return; + } + + if (range.start !== range.end) { + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + return; + } + + if (range.start > 0) { + backend.applyInlineTextEdit({ + blockId, + range: { start: range.start - 1, end: range.start }, + text: "", + }); + } + }, + + deleteContentForward: (_event, editor, ytext, fe, element, backend) => { + if (hasMultiBlockTextSelection(editor)) { + editor.deleteSelection(); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + const target = applyDeleteBehavior(editor, { + blockId, + ytext, + range, + direction: "forward", + }); + if (target) { + if (target.selectBlock) { + fe.deactivate(); + editor.selectBlock(target.blockId); + } else { + fe.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + } + return; + } + + if (range.start < ytext.length) { + backend.applyInlineTextEdit({ + blockId, + range: { start: range.start, end: range.start + 1 }, + text: "", + }); + } + }, + + deleteByCut: (_event, editor, _ytext, fe, element, backend) => { + if (hasMultiBlockTextSelection(editor)) { + editor.deleteSelection(); + return; + } + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range || range.start === range.end) return; + + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + }, + + deleteWordBackward: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + if (range.start !== range.end) { + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + return; + } + + const text = ytext.toString(); + let pos = range.start; + while (pos > 0 && /\s/.test(text[pos - 1])) pos--; + while (pos > 0 && !/\s/.test(text[pos - 1])) pos--; + if (pos < range.start) { + backend.applyInlineTextEdit({ + blockId, + range: { start: pos, end: range.start }, + text: "", + }); + } + }, + + deleteWordForward: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; + const range = backend.resolveCurrentInputRange(); + if (!range) return; + + if (range.start !== range.end) { + backend.applyInlineTextEdit({ + blockId, + range, + text: "", + }); + return; + } + + const text = ytext.toString(); + let pos = range.end; + while (pos < text.length && /\s/.test(text[pos])) pos++; + while (pos < text.length && !/\s/.test(text[pos])) pos++; + if (pos > range.end) { + backend.applyInlineTextEdit({ + blockId, + range: { start: range.end, end: pos }, + text: "", + }); + } + }, + + insertParagraph: (_event, editor, ytext, fe, element, backend) => { + const blockId = fe.focusBlockId; + if (!blockId) return; + const target = applyEnterBehavior(editor, { + blockId, + inputMode: fe.inputMode, + ytext, + range: backend.resolveCurrentInputRange(), + }); + if (!target) return; + + fe.activateTextSelection( + target.blockId, + target.anchorOffset, + target.focusOffset, + ); + }, + + insertLineBreak: (_event, _editor, ytext, fe, element, backend) => { + const range = backend.resolveCurrentInputRange(); + if (!range) return; + const blockId = fe.focusBlockId; + if (!blockId) return; + backend.applyInlineTextEdit({ + blockId, + range, + text: "\n", + marks: fe.resolveInsertMarks(ytext, range.start), + }); + }, + + historyUndo: (_event, editor) => { + editor.undoManager.undo(); + }, + + historyRedo: (_event, editor) => { + editor.undoManager.redo(); + }, + + insertFromPaste: (event, editor, _ytext, fe) => { + const importers = + editor.internals.getSlot("paste:importers"); + handlePaste(event, editor, fe, importers ?? undefined); + }, + + formatBold: (_event, editor) => { + toggleInlineMark(editor, "bold"); + }, + + formatItalic: (_event, editor) => { + toggleInlineMark(editor, "italic"); + }, + + formatUnderline: (_event, editor) => { + toggleInlineMark(editor, "underline"); + }, + + formatStrikeThrough: (_event, editor) => { + toggleInlineMark(editor, "strikethrough"); + }, +}; + +function hasMultiBlockTextSelection(editor: Editor): boolean { + const selection = editor.selection; + return selection?.type === "text" && selection.isMultiBlock; +} diff --git a/packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts b/packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts new file mode 100644 index 0000000..7bd2d4e --- /dev/null +++ b/packages/rendering/dom/src/field-editor/contenteditableDomHelpers.ts @@ -0,0 +1,261 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorDelta } from "./crdt"; +import { domPointToOffset, getSelectionOffsets } from "./selectionBridge"; + +export function requiresResolvedInputRange(inputType: string): boolean { + return ( + inputType === "insertText" || + inputType === "insertReplacementText" || + inputType === "deleteContentBackward" || + inputType === "deleteContentForward" || + inputType === "deleteByCut" || + inputType === "deleteWordBackward" || + inputType === "deleteWordForward" || + inputType === "insertLineBreak" + ); +} + +export function canResolveInputRange( + event: InputEvent, + element: HTMLElement, +): boolean { + if (event.inputType === "insertReplacementText") { + const targetRanges = event.getTargetRanges?.(); + if (targetRanges?.length) { + return staticRangeToOffsets(targetRanges[0], element) !== null; + } + } + + return getSelectionOffsets(element) !== null; +} + +/** + * Convert a StaticRange (from getTargetRanges) to character offsets + * within the inline content element. + */ +export function staticRangeToOffsets( + staticRange: StaticRange, + element: HTMLElement, +): { start: number; end: number } | null { + if ( + (staticRange.startContainer !== element && + !element.contains(staticRange.startContainer)) || + (staticRange.endContainer !== element && + !element.contains(staticRange.endContainer)) + ) { + return null; + } + + const startOffset = domPointToOffset( + element, + staticRange.startContainer, + staticRange.startOffset, + ); + const endOffset = domPointToOffset( + element, + staticRange.endContainer, + staticRange.endOffset, + ); + + return { + start: Math.min(startOffset, endOffset), + end: Math.max(startOffset, endOffset), + }; +} + +export function setSelectionOffsets( + element: HTMLElement, + startOffset: number, + endOffset: number, +): void { + const selection = element.ownerDocument?.getSelection(); + if (!selection) return; + + const startPoint = resolveDomPointForOffset(element, startOffset); + const endPoint = resolveDomPointForOffset(element, endOffset); + if (!startPoint || !endPoint) return; + + selection.removeAllRanges(); + + const setBaseAndExtent = ( + selection as Selection & { + setBaseAndExtent?: ( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, + ) => void; + } + ).setBaseAndExtent; + if (typeof setBaseAndExtent === "function") { + try { + setBaseAndExtent.call( + selection, + startPoint.node, + startPoint.offset, + endPoint.node, + endPoint.offset, + ); + return; + } catch { + // Fall back to the range-based path in non-browser test environments. + } + } + + const collapseRange = element.ownerDocument.createRange(); + collapseRange.setStart(startPoint.node, startPoint.offset); + collapseRange.collapse(true); + selection.addRange(collapseRange); + + if ( + (startPoint.node !== endPoint.node || + startPoint.offset !== endPoint.offset) && + typeof selection.extend === "function" + ) { + selection.extend(endPoint.node, endPoint.offset); + return; + } + + selection.removeAllRanges(); + const range = element.ownerDocument.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + selection.addRange(range); +} + +function resolveDomPointForOffset( + element: HTMLElement, + targetOffset: number, +): { node: Node; offset: number } | null { + const walker = element.ownerDocument.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + ); + let remaining = Math.max(0, targetOffset); + let textNode = walker.nextNode() as Text | null; + + while (textNode) { + const length = textNode.textContent?.length ?? 0; + if (remaining <= length) { + return { node: textNode, offset: remaining }; + } + remaining -= length; + textNode = walker.nextNode() as Text | null; + } + + if (element.lastChild) { + if (element.lastChild.nodeType === Node.TEXT_NODE) { + const textLength = element.lastChild.textContent?.length ?? 0; + return { + node: element.lastChild, + offset: textLength, + }; + } + const childCount = element.lastChild.childNodes.length; + return { node: element.lastChild, offset: childCount }; + } + + return { node: element, offset: 0 }; +} + +export function rebaseTextDiffOps( + ops: Array< + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number } + >, + deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, +): Array< + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number } +> { + if (deferredRemoteDeltas.length === 0 || ops.length === 0) { + return ops; + } + + return ops + .map((op) => { + if (op.type === "insert") { + return { + type: "insert" as const, + offset: mapOffsetThroughRemoteDeltas( + op.offset, + deferredRemoteDeltas, + ), + text: op.text, + }; + } + + const start = mapOffsetThroughRemoteDeltas( + op.offset, + deferredRemoteDeltas, + ); + const end = mapOffsetThroughRemoteDeltas( + op.offset + op.length, + deferredRemoteDeltas, + ); + return { + type: "delete" as const, + offset: start, + length: Math.max(0, end - start), + }; + }) + .filter((op) => { + if (op.type === "insert") { + return true; + } + return op.length > 0; + }); +} + +function mapOffsetThroughRemoteDeltas( + originalOffset: number, + deferredRemoteDeltas: Array<{ delta: FieldEditorDelta[] }>, +): number { + let mappedOffset = originalOffset; + + for (const { delta } of deferredRemoteDeltas) { + let cursor = 0; + for (const part of delta) { + if (part.retain != null) { + cursor += part.retain; + continue; + } + + if (part.delete != null) { + if (cursor < mappedOffset) { + const deletedBeforeOffset = Math.min( + part.delete, + mappedOffset - cursor, + ); + mappedOffset -= deletedBeforeOffset; + } + continue; + } + + if (part.insert != null) { + const insertedLength = + typeof part.insert === "string" ? part.insert.length : 1; + if (cursor <= mappedOffset) { + mappedOffset += insertedLength; + } + cursor += insertedLength; + } + } + } + + return mappedOffset; +} + +export function isNavigationSelectionKey(event: KeyboardEvent): boolean { + return ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" + ); +} diff --git a/packages/rendering/dom/src/field-editor/controller.ts b/packages/rendering/dom/src/field-editor/controller.ts index c67931e..e83ab11 100644 --- a/packages/rendering/dom/src/field-editor/controller.ts +++ b/packages/rendering/dom/src/field-editor/controller.ts @@ -1,6 +1,88 @@ -import type { BlockSchema } from "@pen/types"; +import type { BlockSchema, Editor, FieldEditorFocusOptions } from "@pen/types"; import type { FieldEditorStore } from "./store"; import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; + +export type FieldEditorFocusReason = + | "activate" + | "backend-activate" + | "backend-attach" + | "selection-project" + | "selection-activate" + | "selection-sync" + | "restore" + | "cell" + | "select-all"; + +export type PenFocusAction = + | "activate" + | "attach-backend" + | "focus-dom" + | "project-selection" + | "restore" + | "select-all"; + +export type PenFocusReason = NonNullable; + +export type PenFocusDecision = + | { type: "allow" } + | { type: "allow-passive" } + | { type: "deny" }; + +export interface FieldEditorFocusRequest { + editor: Editor; + target: HTMLElement; + root: HTMLElement | null; + reason: FieldEditorFocusReason; + action: PenFocusAction; + source: PenFocusReason; + blockId: string | null; + passive?: boolean; +} + +export type PenFocusRequest = FieldEditorFocusRequest; + +export interface PenFocusPolicy { + decide(request: FieldEditorFocusRequest): PenFocusDecision; + onDenied?(request: FieldEditorFocusRequest): void; +} + +export type PenFieldEditorFocusOptions = FieldEditorFocusOptions; + +export type PenFocusLifecycleEvent = + | { + type: "field-editor-attached"; + editor: Editor; + root: HTMLElement | null; + } + | { + type: "backend-attach-started" | "backend-attach-completed"; + editor: Editor; + target: HTMLElement; + blockId: string | null; + } + | { + type: "selection-projected"; + editor: Editor; + blockId: string | null; + } + | { + type: "focus-request-denied"; + request: FieldEditorFocusRequest; + } + | { + type: "activation-changed"; + editor: Editor; + activeBlockIds: readonly string[]; + isEditing: boolean; + }; + +export type PenFocusLifecycleListener = ( + event: PenFocusLifecycleEvent, +) => void; export type ActiveCellCoord = { blockId: string; @@ -10,11 +92,7 @@ export type ActiveCellCoord = { type FieldEditorSelectionState = Pick< FieldEditorStore, - | "focusBlockId" - | "selection" - | "inputMode" - | "isEditing" - | "isComposing" + "focusBlockId" | "selection" | "inputMode" | "isEditing" | "isComposing" > & { readonly activeCellCoord: ActiveCellCoord | null; }; @@ -22,6 +100,7 @@ type FieldEditorSelectionState = Pick< export interface FieldEditorRootHandle { setRootElement(element: HTMLElement | null): void; setFocused(focused: boolean): void; + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void; setSelectAllBehavior(behavior: EditorSelectAllBehavior): void; deactivate(): void; activateTextSelection( @@ -29,11 +108,65 @@ export interface FieldEditorRootHandle { anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; + focusTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): Promise; } export interface FieldEditorDomController extends FieldEditorSelectionState { setComposing(composing: boolean): void; + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ): boolean; + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: PenFieldEditorFocusOptions, + ): boolean; + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean; shouldHandleDomSelectionChange(isApplyingSelection: number): boolean; + resetBackendSelectionAuthority(): void; + setBackendSelectionAuthority( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void; + getBackendSelectionAuthority( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null; + hasBackendSelectionAuthority(source: FieldEditorSelectionSource): boolean; + clearBackendSelectionAuthority(source: FieldEditorSelectionSource): void; + applyBackendSelectionUntilNextFrame(): void; + getBackendSelectionApplicationDepth(): number; + setEditContextSelectionSnapshot( + selection: FieldEditorSelectionSnapshot | null, + ): void; + getEditContextSelectionSnapshot( + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null; + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null; + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean; applyDocumentTextSelection( anchor: { blockId: string; offset: number }, focus: { blockId: string; offset: number }, @@ -54,16 +187,24 @@ export interface FieldEditorDomController extends FieldEditorSelectionState { anchorOffset: number, focusOffset: number, ): void; + notifyDomReconciled(blockId?: string): void; activateTextSelection( blockId: string, anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; deactivate(): void; } -export interface FieldEditorKeyboardController - extends Pick { +export interface FieldEditorKeyboardController extends Pick< + FieldEditorSelectionState, + "focusBlockId" | "inputMode" +> { readonly activeCellCoord: ActiveCellCoord | null; activateCell(blockId: string, row: number, col: number): void; activateTextSelection( @@ -71,6 +212,11 @@ export interface FieldEditorKeyboardController anchorOffset: number, focusOffset: number, ): void; + commitProgrammaticTextSelection?( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; deactivate(): void; selectAll(rootElement?: HTMLElement | null): boolean; } @@ -92,11 +238,10 @@ export interface FieldEditorTableNavigationController { deactivate(): void; } -export interface FieldEditorEscapeController - extends Pick< - FieldEditorSelectionState, - "focusBlockId" | "isEditing" | "isComposing" - > { +export interface FieldEditorEscapeController extends Pick< + FieldEditorSelectionState, + "focusBlockId" | "isEditing" | "isComposing" +> { readonly activeCellCoord: ActiveCellCoord | null; collapseSelectionToFocus(): void; deactivate(): void; @@ -120,13 +265,20 @@ export type FieldEditorSession = FieldEditorStore & FieldEditorEscapeController & { beginPointerSelection(): void; endPointerSelection(): void; - selectAll(rootElement?: HTMLElement | null): boolean; - resetSelectAllCycle(): void; - suspendForPointerSelection(): void; - getPendingMarks(): Readonly>; - togglePendingMark(markType: string): boolean; - clearPendingMarks(): void; - collapseSelectionToAnchor(): void; - collapseSelectionToPoint(point: { blockId: string; offset: number }): void; - delegate(blockSchema: BlockSchema): boolean; -}; + selectAll(rootElement?: HTMLElement | null): boolean; + resetSelectAllCycle(): void; + suspendForPointerSelection(): void; + getPendingMarks(): Readonly>; + togglePendingMark(markType: string): boolean; + clearPendingMarks(): void; + collapseSelectionToAnchor(): void; + collapseSelectionToPoint(point: { + blockId: string; + offset: number; + }): void; + onFocusLifecycle( + listener: PenFocusLifecycleListener, + ): () => void; + waitForAttachment(blockId?: string | null): Promise; + delegate(blockSchema: BlockSchema): boolean; + }; diff --git a/packages/rendering/dom/src/field-editor/editContextBackend.ts b/packages/rendering/dom/src/field-editor/editContextBackend.ts index 176f846..995c8f7 100644 --- a/packages/rendering/dom/src/field-editor/editContextBackend.ts +++ b/packages/rendering/dom/src/field-editor/editContextBackend.ts @@ -1,811 +1,3 @@ -import { INPUT_RULES_ENGINE_SLOT_KEY } from "@pen/types"; -import type { DocumentOp, Editor, InlineDecoration, InputBackend } from "@pen/types"; -import { supportsInlineInputRules } from "@pen/types"; -import type { FieldEditorInputController } from "./controller"; -import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; -import { - domSelectionToEditor, - editorSelectionToDOM, - getDirectionalSelectionOffsets, -} from "./selectionBridge"; -import { normalizeSelectionFormation } from "../utils/selectionFormation"; -import { handleFieldEditorKeyDown } from "./keyHandling"; -import { isHistoryTransactionOrigin } from "./historyOrigin"; -import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; -import type { PasteImporters } from "../types/paste"; -import { applyListInputRule } from "./commands"; -import type { - FieldEditorObserver, - FieldEditorTextChangeEvent, - FieldEditorTextLike, - InlineInputRuleEngine, -} from "./crdt"; -import { matchInlineInputRule } from "../utils/inlineInputRule"; +import { EditContextBackendRuntime } from "./editContextBackendRuntime"; -type EditContextTextUpdateEvent = Event & { - updateRangeStart: number; - updateRangeEnd: number; - text: string; - selectionStart?: number; - selectionEnd?: number; -}; - -type EditContextTextFormat = { - rangeStart: number; - rangeEnd: number; - underlineStyle?: string; - underlineThickness?: string; -}; - -type EditContextTextFormatUpdateEvent = Event & { - getTextFormats?(): EditContextTextFormat[]; -}; - -type EditContextCharacterBoundsUpdateEvent = Event & { - rangeStart: number; - rangeEnd: number; -}; - -declare class EditContext { - constructor(options?: { - text?: string; - selectionStart?: number; - selectionEnd?: number; - }); - updateText(start: number, end: number, text: string): void; - updateSelection(start: number, end: number): void; - updateCharacterBounds(start: number, rects: DOMRect[]): void; - addEventListener(type: string, handler: (event: Event) => void): void; - removeEventListener(type: string, handler: (event: Event) => void): void; - readonly text: string; - readonly selectionStart: number; - readonly selectionEnd: number; -} - -type EditContextConstructor = typeof EditContext; -type EditContextGlobal = typeof globalThis & { - EditContext?: EditContextConstructor; -}; - -export class EditContextBackend implements InputBackend { - private editContext: EditContext | null = null; - private element: HTMLElement | null = null; - private ytext: FieldEditorTextLike | null = null; - private observer: FieldEditorObserver | null = null; - private isApplyingSelection = 0; - private pendingSelectionOverride: { - blockId: string; - anchorOffset: number; - focusOffset: number; - } | null = null; - private editor: Editor; - private fieldEditor: FieldEditorInputController; - - constructor(editor: Editor, fieldEditor: FieldEditorInputController) { - this.editor = editor; - this.fieldEditor = fieldEditor; - } - - activate(element: HTMLElement, ytext: unknown): void { - this.element = element; - this.ytext = ytext as FieldEditorTextLike; - this.fieldEditor.setComposing(false); - - const editContextConstructor = (globalThis as EditContextGlobal).EditContext; - if (!editContextConstructor) { - throw new Error("EditContext is not available in this environment."); - } - - this.editContext = new editContextConstructor({ - text: this.ytext.toString(), - selectionStart: 0, - selectionEnd: 0, - }); - - const ec = this.editContext!; - - ( - element as HTMLElement & { editContext: EditContext | null } - ).editContext = ec; - - element.addEventListener("keydown", this.handleKeyDown); - element.addEventListener("copy", this.handleCopyEvent); - element.addEventListener("cut", this.handleCutEvent); - element.addEventListener("paste", this.handlePasteEvent); - element.addEventListener("dragstart", this.handleDragStart); - element.addEventListener("drop", this.handleDrop); - ec.addEventListener("textupdate", this.handleTextUpdate); - ec.addEventListener("textformatupdate", this.handleTextFormatUpdate); - ec.addEventListener( - "characterboundsupdate", - this.handleCharacterBoundsUpdate, - ); - element.ownerDocument?.addEventListener( - "selectionchange", - this.handleSelectionChange, - ); - - this.observer = (event) => this.handleYTextChange(event); - this.ytext.observe(this.observer); - - fullReconcileToDOM(this.ytext, element, this.editor.schema, { - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - this.isApplyingSelection++; - this.updateSelection(null); - element.focus({ preventScroll: true }); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); - } - - deactivate(): void { - if (this.editContext) { - this.editContext.removeEventListener( - "textupdate", - this.handleTextUpdate, - ); - this.editContext.removeEventListener( - "textformatupdate", - this.handleTextFormatUpdate, - ); - this.editContext.removeEventListener( - "characterboundsupdate", - this.handleCharacterBoundsUpdate, - ); - } - if (this.observer && this.ytext) { - this.ytext.unobserve(this.observer); - } - if (this.element) { - this.element.removeEventListener("keydown", this.handleKeyDown); - this.element.removeEventListener("copy", this.handleCopyEvent); - this.element.removeEventListener("cut", this.handleCutEvent); - this.element.removeEventListener("paste", this.handlePasteEvent); - this.element.removeEventListener("dragstart", this.handleDragStart); - this.element.removeEventListener("drop", this.handleDrop); - this.element.ownerDocument?.removeEventListener( - "selectionchange", - this.handleSelectionChange, - ); - ( - this.element as HTMLElement & { - editContext: EditContext | null; - } - ).editContext = null; - } - this.editContext = null; - this.element = null; - this.ytext = null; - this.observer = null; - this.pendingSelectionOverride = null; - this.fieldEditor.setComposing(false); - } - - updateSelection(_relPos: unknown): void { - if (!this.editContext || !this.ytext) return; - - const selection = this.fieldEditor.selection; - const blockId = this.fieldEditor.focusBlockId; - if ( - selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ) { - this.editContext.updateSelection( - selection.anchor.offset, - selection.focus.offset, - ); - this.isApplyingSelection++; - this.projectDOMSelection( - blockId, - selection.anchor.offset, - selection.focus.offset, - ); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); - return; - } - - const len = this.ytext.length; - this.editContext.updateSelection(len, len); - } - - private projectDOMSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - if (!this.element) return; - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - editorSelectionToDOM( - root, - { blockId, offset: anchorOffset }, - { blockId, offset: focusOffset }, - ); - } - - private handleTextUpdate = (event: Event): void => { - if (!this.ytext) return; - const { - updateRangeStart, - updateRangeEnd, - text, - selectionStart, - selectionEnd, - } = event as EditContextTextUpdateEvent; - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) return; - - const block = this.editor.getBlock(blockId); - if (!block) { - this.fieldEditor.deactivate(); - return; - } - - const range = { - start: updateRangeStart, - end: updateRangeEnd, - }; - const listInputRuleTarget = applyListInputRule(this.editor, { - blockId, - range, - text, - }); - if (listInputRuleTarget) { - this.pendingSelectionOverride = { - blockId: listInputRuleTarget.blockId, - anchorOffset: listInputRuleTarget.anchorOffset, - focusOffset: listInputRuleTarget.focusOffset, - }; - this.fieldEditor.syncTextSelection( - listInputRuleTarget.blockId, - listInputRuleTarget.anchorOffset, - listInputRuleTarget.focusOffset, - ); - this.restoreDOMCaret(); - this.pendingSelectionOverride = null; - return; - } - - const inlineInputRuleTarget = this.applyInlineInputRule( - blockId, - range.start, - text, - ); - if (inlineInputRuleTarget) { - this.pendingSelectionOverride = inlineInputRuleTarget; - this.fieldEditor.syncTextSelection( - inlineInputRuleTarget.blockId, - inlineInputRuleTarget.anchorOffset, - inlineInputRuleTarget.focusOffset, - ); - this.restoreDOMCaret(); - this.pendingSelectionOverride = null; - return; - } - - this.pendingSelectionOverride = - typeof selectionStart === "number" && - typeof selectionEnd === "number" - ? { - blockId, - anchorOffset: selectionStart, - focusOffset: selectionEnd, - } - : null; - - const ops: DocumentOp[] = []; - if (updateRangeEnd > updateRangeStart) { - ops.push({ - type: "delete-text" as const, - blockId, - offset: range.start, - length: range.end - range.start, - }); - } - if (text.length > 0) { - ops.push({ - type: "insert-text" as const, - blockId, - offset: range.start, - text, - marks: this.fieldEditor.resolveInsertMarks(this.ytext, range.start), - }); - } - if (ops.length > 0) { - this.editor.apply(ops, { origin: "user" }); - } - - if ( - typeof selectionStart === "number" && - typeof selectionEnd === "number" - ) { - this.fieldEditor.syncTextSelection( - blockId, - selectionStart, - selectionEnd, - ); - this.restoreDOMCaret(); - } - - this.pendingSelectionOverride = null; - }; - - private applyInlineInputRule( - blockId: string, - offset: number, - text: string, - ): - | { - blockId: string; - anchorOffset: number; - focusOffset: number; - } - | null { - if (text.length !== 1) { - return null; - } - - const block = this.editor.getBlock(blockId); - if (!block) { - return null; - } - - const blockSchema = this.editor.schema.resolve(block.type); - if (!supportsInlineInputRules(blockSchema)) { - return null; - } - - const inputRuleEngine = - this.editor.internals.getSlot( - INPUT_RULES_ENGINE_SLOT_KEY, - ) ?? null; - const ops = - inputRuleEngine?.tryMatchInline(this.editor, blockId, text, { - offset, - }) ?? this.resolveFallbackInlineInputRule(blockId, block.textContent(), offset, text); - if (!ops) { - return null; - } - - const selectionTarget = resolveInlineSelectionTarget(blockId, ops); - if (!selectionTarget) { - return null; - } - - this.editor.apply(ops, { origin: "input-rule" }); - return selectionTarget; - } - - private resolveFallbackInlineInputRule( - blockId: string, - blockText: string, - offset: number, - text: string, - ): DocumentOp[] | null { - const match = matchInlineInputRule(blockText, offset, text); - if (!match) { - return null; - } - - const markType = Object.keys(match.marks)[0]; - if (!markType || !this.editor.schema.resolveInline(markType)) { - return null; - } - - return [ - { - type: "delete-text", - blockId, - offset: match.deleteRange.start, - length: match.deleteRange.end - match.deleteRange.start, - }, - { - type: "insert-text", - blockId, - offset: match.deleteRange.start, - text: match.text, - marks: match.marks, - }, - ]; - } - - private handleTextFormatUpdate = (event: Event): void => { - // IME composition underline rendering. - // The textformatupdate event provides ranges with underline styles - // for visual feedback during IME composition. These are rendered - // as ephemeral decorations (not CRDT marks) and cleared when - // textupdate confirms the final text. - if (!this.element) return; - - const ranges = - (event as EditContextTextFormatUpdateEvent).getTextFormats?.() ?? []; - for (const fmt of ranges) { - const { rangeStart, rangeEnd, underlineStyle, underlineThickness } = - fmt; - if (!underlineStyle) continue; - - // Apply inline decoration-style attributes via mark wrappers. - // This is a visual-only effect that doesn't modify the CRDT. - const inlineEls = this.element.querySelectorAll( - "[data-pen-inline-content]", - ); - for (const el of inlineEls) { - const walker = document.createTreeWalker( - el, - NodeFilter.SHOW_TEXT, - null, - ); - let offset = 0; - let textNode: Text | null; - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - const segStart = offset; - const segEnd = offset + len; - if (segEnd > rangeStart && segStart < rangeEnd) { - const parentEl = textNode.parentElement; - if (parentEl) { - parentEl.style.textDecoration = underlineStyle; - if (underlineThickness) { - parentEl.style.textDecorationThickness = - underlineThickness; - } - } - } - offset += len; - } - } - } - }; - - private handleCharacterBoundsUpdate = (event: Event): void => { - if (!this.element || !this.editContext) return; - - const { rangeStart, rangeEnd } = - event as EditContextCharacterBoundsUpdateEvent; - const rects: DOMRect[] = []; - - for (let i = rangeStart; i < rangeEnd; i++) { - const rect = getCharacterRect(this.element, i); - rects.push(rect); - } - - this.editContext.updateCharacterBounds(rangeStart, rects); - }; - - private handleSelectionChange = (): void => { - if (!this.element || !this.editContext) return; - if (!this.fieldEditor.shouldHandleDomSelectionChange(this.isApplyingSelection)) { - return; - } - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - if (!root) return; - - const mappedSelection = domSelectionToEditor(root); - if (!mappedSelection) return; - const normalizedSelection = normalizeSelectionFormation( - this.editor, - mappedSelection, - ); - - if (normalizedSelection.type === "block") { - this.fieldEditor.deactivate(); - this.editor.setSelection({ - type: "block", - blockIds: normalizedSelection.blockIds, - }); - return; - } - - if ( - normalizedSelection.anchor.blockId !== normalizedSelection.focus.blockId - ) { - this.fieldEditor.applyDocumentTextSelection( - normalizedSelection.anchor, - normalizedSelection.focus, - ); - return; - } - - if ( - normalizedSelection.anchor.blockId !== this.fieldEditor.focusBlockId - ) { - this.fieldEditor.activateTextSelection( - normalizedSelection.anchor.blockId, - normalizedSelection.anchor.offset, - normalizedSelection.focus.offset, - ); - return; - } - - const selection = this.element.ownerDocument?.getSelection(); - if (!selection?.rangeCount) return; - if (!this.element.contains(selection.anchorNode)) return; - if (!this.element.contains(selection.focusNode)) return; - - const offsets = getDirectionalSelectionOffsets(this.element); - if (!offsets) return; - - this.editContext.updateSelection(offsets.start, offsets.end); - this.fieldEditor.syncTextSelection( - normalizedSelection.anchor.blockId, - offsets.anchor, - offsets.focus, - ); - }; - - private handleYTextChange = (event: FieldEditorTextChangeEvent): void => { - if (!this.editContext || !this.element || !this.ytext) return; - const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); - if (isHistory) { - const nextText = this.ytext?.toString?.() ?? ""; - this.editContext.updateText(0, this.editContext.text.length, nextText); - const clampedSelectionStart = Math.min( - this.editContext.selectionStart, - nextText.length, - ); - const clampedSelectionEnd = Math.min( - this.editContext.selectionEnd, - nextText.length, - ); - this.editContext.updateSelection( - clampedSelectionStart, - clampedSelectionEnd, - ); - return; - } - - const delta = event.delta; - let offset = 0; - for (const entry of delta) { - if (entry.retain != null) { - offset += entry.retain; - } else if (typeof entry.insert === "string") { - this.editContext.updateText(offset, offset, entry.insert); - offset += entry.insert.length; - } else if (entry.delete != null) { - this.editContext.updateText(offset, offset + entry.delete, ""); - } - } - - const applied = applyDeltaToDOM( - event.delta, - this.element, - this.editor.schema, - ); - if (!applied) { - fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { - preserveSelection: true, - inlineDecorations: this.getInlineDecorationsForBlock(), - }); - } - - this.restoreDOMCaret(); - }; - - private restoreDOMCaret(): void { - if (!this.editContext || !this.element) return; - - const root = this.element.closest( - "[data-pen-editor-root]", - ) as HTMLElement | null; - const selection = this.fieldEditor.selection; - const blockId = this.fieldEditor.focusBlockId; - const pendingSelection = - blockId != null && - this.pendingSelectionOverride?.blockId === blockId - ? this.pendingSelectionOverride - : null; - const anchorOffset = - pendingSelection?.anchorOffset ?? - (selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? selection.anchor.offset - : null); - const focusOffset = - pendingSelection?.focusOffset ?? - (selection?.type === "text" && - blockId && - selection.anchor.blockId === blockId && - selection.focus.blockId === blockId - ? selection.focus.offset - : null); - if (root && blockId && anchorOffset != null && focusOffset != null) { - editorSelectionToDOM( - root, - { blockId, offset: anchorOffset }, - { blockId, offset: focusOffset }, - ); - return; - } - - const start = this.editContext.selectionStart; - const end = this.editContext.selectionEnd; - - const anchorPoint = findTextPosition(this.element, start); - const focusPoint = - start === end ? anchorPoint : findTextPosition(this.element, end); - if (!anchorPoint || !focusPoint) return; - - const sel = this.element.ownerDocument?.getSelection(); - if (!sel) return; - - sel.removeAllRanges(); - const range = document.createRange(); - range.setStart(anchorPoint.node, anchorPoint.offset); - range.setEnd(focusPoint.node, focusPoint.offset); - sel.addRange(range); - } - - private getInlineDecorationsForBlock(): readonly InlineDecoration[] { - const blockId = this.fieldEditor.focusBlockId; - if (!blockId) { - return []; - } - return this.editor - .getDecorations() - .forBlock(blockId) - .filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", - ); - } - - private handleKeyDown = (event: KeyboardEvent): void => { - if (!this.editContext || !this.element || !this.ytext) return; - - const liveDomOffsets = getDirectionalSelectionOffsets(this.element); - const range = liveDomOffsets - ? { - start: liveDomOffsets.start, - end: liveDomOffsets.end, - } - : { - start: Math.min( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - end: Math.max( - this.editContext.selectionStart, - this.editContext.selectionEnd, - ), - }; - if (liveDomOffsets) { - this.editContext.updateSelection(range.start, range.end); - } - - const handled = handleFieldEditorKeyDown({ - event, - editor: this.editor, - fieldEditor: this.fieldEditor, - ytext: this.ytext, - range, - }); - if (handled) { - event.preventDefault(); - } - }; - - private handleCopyEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCopy(this.editor, event); - }; - - private handleCutEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - handleCut(this.editor, event); - }; - - private handlePasteEvent = (event: ClipboardEvent): void => { - event.preventDefault(); - const importers = - this.editor.internals.getSlot("paste:importers"); - handleClipboardPaste( - event, - this.editor, - this.fieldEditor, - importers ?? undefined, - ); - }; - - private handleDragStart = (event: DragEvent): void => { - event.preventDefault(); - }; - - private handleDrop = (event: DragEvent): void => { - event.preventDefault(); - }; -} - -function resolveInlineSelectionTarget( - blockId: string, - ops: DocumentOp[], -): - | { - blockId: string; - anchorOffset: number; - focusOffset: number; - } - | null { - let nextOffset: number | null = null; - for (const op of ops) { - if (op.type === "insert-text" && op.blockId === blockId) { - nextOffset = op.offset + op.text.length; - } - } - - if (nextOffset == null) { - return null; - } - - return { - blockId, - anchorOffset: nextOffset, - focusOffset: nextOffset, - }; -} - -/** - * Get the DOMRect for a character at the given offset within the element. - * Walks text nodes to locate the character, then uses Range.getBoundingClientRect(). - */ -function getCharacterRect(element: HTMLElement, charOffset: number): DOMRect { - const walker = document.createTreeWalker( - element, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = charOffset; - let textNode: Text | null; - - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining < len) { - const range = document.createRange(); - range.setStart(textNode, remaining); - range.setEnd(textNode, remaining + 1); - return range.getBoundingClientRect(); - } - remaining -= len; - } - - // Fallback: return the element's bounding rect - return element.getBoundingClientRect(); -} - -function findTextPosition( - container: HTMLElement, - charOffset: number, -): { node: Node; offset: number } | null { - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = charOffset; - let textNode: Text | null; - - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining <= len) { - return { node: textNode, offset: remaining }; - } - remaining -= len; - } - - const last = container.lastChild; - if (last) { - return { node: last, offset: last.textContent?.length ?? 0 }; - } - return { node: container, offset: 0 }; -} +export class EditContextBackend extends EditContextBackendRuntime {} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendCore.ts b/packages/rendering/dom/src/field-editor/editContextBackendCore.ts new file mode 100644 index 0000000..ecdf731 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendCore.ts @@ -0,0 +1,278 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; + +export type EditContextSelectionOptions = { + source?: "text-update"; +}; + +export abstract class EditContextBackendCore { + protected editContext: EditContext | null = null; + protected element: HTMLElement | null = null; + protected ytext: FieldEditorTextLike | null = null; + protected observer: FieldEditorObserver | null = null; + protected unsubscribeDecorationsChange: (() => void) | null = null; + protected inlineDecorationsSignature: string | null = null; + protected editor: Editor; + protected fieldEditor: FieldEditorInputController; + + constructor(editor: Editor, fieldEditor: FieldEditorInputController) { + this.editor = editor; + this.fieldEditor = fieldEditor; + } + + activate(element: HTMLElement, ytext: unknown): void { + this.element = element; + this.ytext = ytext as FieldEditorTextLike; + this.fieldEditor.setComposing(false); + + const editContextConstructor = (globalThis as EditContextGlobal) + .EditContext; + if (!editContextConstructor) { + throw new Error( + "EditContext is not available in this environment.", + ); + } + + const initialText = this.ytext.toString(); + const initialEditContextText = toEditContextText(initialText); + const initialSelectionOffset = isLogicallyEmptyText(initialText) + ? 0 + : initialEditContextText.length; + this.editContext = new editContextConstructor({ + text: initialEditContextText, + selectionStart: initialSelectionOffset, + selectionEnd: initialSelectionOffset, + }); + + const ec = this.editContext!; + + ( + element as HTMLElement & { editContext: EditContext | null } + ).editContext = ec; + + element.addEventListener("keydown", this.handleKeyDown); + element.addEventListener("copy", this.handleCopyEvent); + element.addEventListener("cut", this.handleCutEvent); + element.addEventListener("paste", this.handlePasteEvent); + element.addEventListener("dragstart", this.handleDragStart); + element.addEventListener("drop", this.handleDrop); + element.addEventListener("pointerdown", this.handlePointerDown); + ec.addEventListener("textupdate", this.handleTextUpdate); + ec.addEventListener("textformatupdate", this.handleTextFormatUpdate); + ec.addEventListener( + "characterboundsupdate", + this.handleCharacterBoundsUpdate, + ); + element.ownerDocument?.addEventListener( + "selectionchange", + this.handleSelectionChange, + ); + + this.observer = (event) => this.handleYTextChange(event); + this.ytext.observe(this.observer); + this.unsubscribeDecorationsChange = this.editor.on( + "decorationsChange", + this.handleDecorationsChange, + ); + this.inlineDecorationsSignature = this.getInlineDecorationsSignature(); + + fullReconcileToDOM(this.ytext, element, this.editor.schema, { + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + this.updateSelection(); + this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }); + } + + deactivate(): void { + if (this.editContext) { + this.editContext.removeEventListener( + "textupdate", + this.handleTextUpdate, + ); + this.editContext.removeEventListener( + "textformatupdate", + this.handleTextFormatUpdate, + ); + this.editContext.removeEventListener( + "characterboundsupdate", + this.handleCharacterBoundsUpdate, + ); + } + if (this.observer && this.ytext) { + this.ytext.unobserve(this.observer); + } + this.unsubscribeDecorationsChange?.(); + this.unsubscribeDecorationsChange = null; + if (this.element) { + this.element.removeEventListener("keydown", this.handleKeyDown); + this.element.removeEventListener("copy", this.handleCopyEvent); + this.element.removeEventListener("cut", this.handleCutEvent); + this.element.removeEventListener("paste", this.handlePasteEvent); + this.element.removeEventListener("dragstart", this.handleDragStart); + this.element.removeEventListener("drop", this.handleDrop); + this.element.removeEventListener( + "pointerdown", + this.handlePointerDown, + ); + this.element.ownerDocument?.removeEventListener( + "selectionchange", + this.handleSelectionChange, + ); + ( + this.element as HTMLElement & { + editContext: EditContext | null; + } + ).editContext = null; + } + this.editContext = null; + this.element = null; + this.ytext = null; + this.observer = null; + this.inlineDecorationsSignature = null; + this.fieldEditor.resetBackendSelectionAuthority(); + this.fieldEditor.setComposing(false); + } + + updateSelection(): void { + if (!this.editContext || !this.ytext) return; + + const selection = this.fieldEditor.selection; + const blockId = this.fieldEditor.focusBlockId; + if ( + selection?.type === "text" && + blockId && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ) { + const anchorOffset = this.resolveEditContextOffset( + selection.anchor.offset, + ); + const focusOffset = this.resolveEditContextOffset( + selection.focus.offset, + ); + this.setEditContextSelection({ + blockId, + anchorOffset, + focusOffset, + }); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + this.projectDOMSelection(blockId, anchorOffset, focusOffset); + return; + } + + const len = isLogicallyEmptyText(this.ytext.toString()) + ? 0 + : this.ytext.length; + this.editContext.updateSelection(len, len); + this.fieldEditor.setEditContextSelectionSnapshot( + blockId + ? { + blockId, + anchorOffset: len, + focusOffset: len, + } + : null, + ); + } + + protected projectDOMSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + if (!this.element) return; + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + editorSelectionToDOM( + root, + { blockId, offset: anchorOffset }, + { blockId, offset: focusOffset }, + ); + } + + protected abstract handleTextUpdate: (event: Event) => void; + protected abstract handleTextFormatUpdate: (event: Event) => void; + protected abstract handleCharacterBoundsUpdate: (event: Event) => void; + protected abstract handleSelectionChange: () => void; + protected abstract handleYTextChange: (event: FieldEditorTextChangeEvent) => void; + protected abstract handleDecorationsChange: () => void; + protected abstract handleKeyDown: (event: KeyboardEvent) => void; + protected abstract handleCopyEvent: (event: ClipboardEvent) => void; + protected abstract handleCutEvent: (event: ClipboardEvent) => void; + protected abstract handlePasteEvent: (event: ClipboardEvent) => void; + protected abstract handleDragStart: (event: DragEvent) => void; + protected abstract handleDrop: (event: DragEvent) => void; + protected abstract handlePointerDown: () => void; + protected abstract restoreDOMCaret(): void; + protected abstract getInlineDecorationsForBlock(): readonly InlineDecoration[]; + protected abstract getInlineDecorationsSignature(): string; + protected abstract setEditContextSelection( + selection: EditContextSelection, + options?: EditContextSelectionOptions, + ): void; + protected abstract resolveEditContextOffset( + offset: number, + options?: EditContextSelectionOptions, + ): number; + protected abstract resolveCollapsedEditorSelectionRange( + blockId: string, + ): EditContextRange | null; + protected abstract getAuthoritativeTextInputSelection( + blockId: string, + ): EditContextSelection | null; +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendInput.ts b/packages/rendering/dom/src/field-editor/editContextBackendInput.ts new file mode 100644 index 0000000..5172958 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendInput.ts @@ -0,0 +1,310 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { + EditContextBackendCore, + type EditContextSelectionOptions, +} from "./editContextBackendCore"; + +export abstract class EditContextBackendInput extends EditContextBackendCore { + protected handleTextUpdate = (event: Event): void => { + if (!this.ytext) return; + const { + updateRangeStart, + updateRangeEnd, + text, + selectionStart, + selectionEnd, + } = event as EditContextTextUpdateEvent; + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) return; + + const block = this.editor.getBlock(blockId); + if (!block) { + this.fieldEditor.deactivate(); + return; + } + + const resolvedTextUpdate = this.resolveTextUpdateRange({ + blockId, + updateRangeStart, + updateRangeEnd, + text, + selectionStart, + selectionEnd, + }); + const { range } = resolvedTextUpdate; + const listInputRuleTarget = applyListInputRule(this.editor, { + blockId, + range, + text, + }); + if (listInputRuleTarget) { + const nextSelection = { + blockId: listInputRuleTarget.blockId, + anchorOffset: listInputRuleTarget.anchorOffset, + focusOffset: listInputRuleTarget.focusOffset, + }; + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + nextSelection, + ); + this.setEditContextSelection(nextSelection, { + source: "text-update", + }); + this.fieldEditor.syncTextSelection( + listInputRuleTarget.blockId, + listInputRuleTarget.anchorOffset, + listInputRuleTarget.focusOffset, + ); + this.restoreDOMCaret(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + return; + } + + const inlineInputRuleTarget = applyInlineInputRule(this.editor, { + blockId, + offset: range.start, + text, + }); + if (inlineInputRuleTarget) { + this.fieldEditor.setBackendSelectionAuthority( + "programmatic", + inlineInputRuleTarget, + ); + this.setEditContextSelection(inlineInputRuleTarget, { + source: "text-update", + }); + this.fieldEditor.syncTextSelection( + inlineInputRuleTarget.blockId, + inlineInputRuleTarget.anchorOffset, + inlineInputRuleTarget.focusOffset, + ); + this.restoreDOMCaret(); + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + return; + } + + const selection = applyInlineTextInput({ + editor: this.editor, + fieldEditor: this.fieldEditor, + blockId, + range, + text, + marks: this.fieldEditor.resolveInsertMarks( + this.ytext, + range.start, + ), + selection: resolvedTextUpdate.selection, + syncSelection: resolvedTextUpdate.selection != null, + }); + + if (resolvedTextUpdate.selection) { + this.setEditContextSelection(selection, { + source: "text-update", + }); + this.fieldEditor.syncTextSelection( + blockId, + selection.anchorOffset, + selection.focusOffset, + ); + this.restoreDOMCaret(); + } + + this.fieldEditor.clearBackendSelectionAuthority("programmatic"); + }; + + protected resolveTextUpdateRange(input: { + blockId: string; + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; + }): { + range: { start: number; end: number }; + selection: EditContextSelection | null; + } { + const selection = this.fieldEditor.selection; + const editorCaret = + selection?.type === "text" && + selection.isCollapsed && + selection.focus.blockId === input.blockId + ? selection.focus.offset + : null; + + return resolveEditContextTextUpdateRange({ + ...input, + isLogicallyEmpty: isLogicallyEmptyText( + this.ytext?.toString() ?? "", + ), + editorSelectionRange: this.resolveEditorSelectionRange( + input.blockId, + ), + programmaticInputRange: + this.fieldEditor.resolveProgrammaticInputRange(input.blockId, { + start: input.updateRangeStart, + end: input.updateRangeEnd, + }), + editContextSelection: + this.fieldEditor.getEditContextSelectionSnapshot( + input.blockId, + ), + authoritativeTextInputSelection: + this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + input.blockId, + ), + editorCaret, + }); + } + + protected setEditContextSelection( + selection: EditContextSelection, + options?: EditContextSelectionOptions, + ): void { + const resolvedSelection = { + blockId: selection.blockId, + anchorOffset: this.resolveEditContextOffset( + selection.anchorOffset, + options, + ), + focusOffset: this.resolveEditContextOffset( + selection.focusOffset, + options, + ), + }; + this.fieldEditor.setEditContextSelectionSnapshot(resolvedSelection); + if (options?.source === "text-update") { + this.fieldEditor.setBackendSelectionAuthority( + "edit-context-textupdate", + resolvedSelection, + ); + } + this.editContext?.updateSelection( + resolvedSelection.anchorOffset, + resolvedSelection.focusOffset, + ); + } + + protected resolveEditContextOffset( + offset: number, + options?: EditContextSelectionOptions, + ): number { + return options?.source !== "text-update" && + isLogicallyEmptyText(this.ytext?.toString() ?? "") + ? 0 + : offset; + } + + protected resolveEditorSelectionRange( + blockId: string, + ): EditContextRange | null { + const selection = this.fieldEditor.selection; + if ( + selection?.type !== "text" || + selection.isCollapsed || + selection.anchor.blockId !== blockId || + selection.focus.blockId !== blockId + ) { + return null; + } + + return { + start: Math.min(selection.anchor.offset, selection.focus.offset), + end: Math.max(selection.anchor.offset, selection.focus.offset), + }; + } + + protected shouldIgnoreStaleCollapsedDomSelection( + selection: ReturnType, + ): boolean { + if (selection.type === "block") { + return false; + } + if ( + selection.anchor.blockId !== selection.focus.blockId || + selection.anchor.offset !== selection.focus.offset + ) { + return false; + } + + const editorSelectionRange = + this.resolveEditorSelectionRange(selection.anchor.blockId) ?? + this.resolveCollapsedEditorSelectionRange(selection.anchor.blockId); + if (!editorSelectionRange) { + return false; + } + + return ( + selection.anchor.offset !== editorSelectionRange.start || + selection.focus.offset !== editorSelectionRange.end + ); + } + + protected handleTextFormatUpdate = (event: Event): void => { + if (!this.element) return; + + const ranges = + (event as EditContextTextFormatUpdateEvent).getTextFormats?.() ?? + []; + applyEditContextTextFormats(this.element, ranges); + }; + + protected handleCharacterBoundsUpdate = (event: Event): void => { + if (!this.element || !this.editContext) return; + + const { rangeStart, rangeEnd } = + event as EditContextCharacterBoundsUpdateEvent; + this.editContext.updateCharacterBounds( + rangeStart, + buildEditContextCharacterBounds(this.element, rangeStart, rangeEnd), + ); + }; + +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts b/packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts new file mode 100644 index 0000000..da934bd --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendRuntime.ts @@ -0,0 +1,246 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { EditContextBackendSelection } from "./editContextBackendSelection"; + +export class EditContextBackendRuntime extends EditContextBackendSelection { + protected handleKeyDown = (event: KeyboardEvent): void => { + if (!this.editContext || !this.element || !this.ytext) return; + if (isNavigationSelectionKey(event)) { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); + } + + const blockId = this.fieldEditor.focusBlockId; + const liveDomOffsets = getDirectionalSelectionOffsets(this.element); + const { range, nextSelection, shouldSyncEditContextSelection } = + this.resolveKeyDownRange(blockId, event, liveDomOffsets); + + if (shouldSyncEditContextSelection) { + this.editContext.updateSelection(range.start, range.end); + this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); + } + + const handled = handleFieldEditorKeyDown({ + event, + editor: this.editor, + fieldEditor: this.fieldEditor, + ytext: this.ytext, + range, + }); + if (handled) { + event.preventDefault(); + } + }; + + protected resolveKeyDownRange( + blockId: string | null, + event: KeyboardEvent, + liveDomOffsets: DirectionalSelectionOffsets | null, + ): KeyDownRangeResolution { + const isTextEditingKey = isFieldEditorTextEditingKey(event); + const liveRange = liveDomOffsets + ? { + start: liveDomOffsets.start, + end: liveDomOffsets.end, + } + : null; + return resolveEditContextKeyDownRange({ + blockId, + isTextEditingKey, + liveDomOffsets, + editContextRange: this.resolveEditContextSelectionRange(), + editorSelectionRange: blockId + ? this.resolveEditorSelectionRange(blockId) + : null, + programmaticInputRange: + blockId && isTextEditingKey + ? this.fieldEditor.resolveProgrammaticInputRange( + blockId, + liveRange, + ) + : null, + authoritativeTextInputSelection: blockId + ? this.getAuthoritativeTextInputSelection(blockId) + : null, + collapsedEditorSelectionRange: blockId + ? this.resolveCollapsedEditorSelectionRange(blockId) + : null, + projectedTextSelection: blockId + ? this.getProjectedTextSelection(blockId) + : null, + synchronizedEditContextRange: blockId + ? this.resolveSynchronizedEditContextRange(blockId) + : null, + }); + } + + protected resolveEditContextSelectionRange(): EditContextRange { + if (!this.editContext) { + return { start: 0, end: 0 }; + } + + return { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + } + + protected getProjectedTextSelection( + blockId: string, + ): EditContextSelection | null { + return this.fieldEditor.getEditContextSelectionSnapshot(blockId); + } + + protected resolveCollapsedEditorSelectionRange( + blockId: string, + ): EditContextRange | null { + const selection = this.fieldEditor.selection; + if ( + selection?.type === "text" && + selection.isCollapsed && + selection.focus.blockId === blockId + ) { + return { + start: selection.focus.offset, + end: selection.focus.offset, + }; + } + + return null; + } + + protected resolveSynchronizedEditContextRange( + blockId: string, + ): EditContextRange | null { + if (!this.editContext) { + return null; + } + + const editContextRange = { + start: Math.min( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + end: Math.max( + this.editContext.selectionStart, + this.editContext.selectionEnd, + ), + }; + const editorRange = + this.resolveEditorSelectionRange(blockId) ?? + this.resolveCollapsedEditorSelectionRange(blockId); + + if (editorRange && rangesEqual(editContextRange, editorRange)) { + return editContextRange; + } + + return null; + } + + protected handleCopyEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCopy(this.editor, event); + }; + + protected handleCutEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + handleCut(this.editor, event); + }; + + protected handlePasteEvent = (event: ClipboardEvent): void => { + event.preventDefault(); + const importers = + this.editor.internals.getSlot("paste:importers"); + handleClipboardPaste( + event, + this.editor, + this.fieldEditor, + importers ?? undefined, + ); + }; + + protected handleDragStart = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handleDrop = (event: DragEvent): void => { + event.preventDefault(); + }; + + protected handlePointerDown = (): void => { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); + }; + + protected getAuthoritativeTextInputSelection( + blockId: string, + ): EditContextSelection | null { + const selection = + this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + blockId, + ); + if (!selection || selection.anchorOffset !== selection.focusOffset) { + return null; + } + return { + blockId: selection.blockId, + anchorOffset: selection.anchorOffset, + focusOffset: selection.focusOffset, + }; + } +} diff --git a/packages/rendering/dom/src/field-editor/editContextBackendSelection.ts b/packages/rendering/dom/src/field-editor/editContextBackendSelection.ts new file mode 100644 index 0000000..82eda03 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextBackendSelection.ts @@ -0,0 +1,400 @@ +import type { Editor, InlineDecoration } from "@pen/types"; +import type { FieldEditorInputController } from "./controller"; +import { fullReconcileToDOM, applyDeltaToDOM } from "./reconciler"; +import { + domSelectionToEditor, + editorSelectionToDOM, + getDirectionalSelectionOffsets, +} from "./selectionBridge"; +import { + collapsedSelectionOffset, + rangesEqual, + resolveEditContextKeyDownRange, + resolveEditContextTextUpdateRange, + type DirectionalSelectionOffsets, + type EditContextRange, + type EditContextSelection, + type KeyDownRangeResolution, +} from "./editContextSelectionAuthority"; +import { + applyEditContextTextFormats, + buildEditContextCharacterBounds, + findTextPosition, + isLogicallyEmptyText, + isNavigationSelectionKey, + shouldReplaceEditContextText, + toEditContextText, +} from "./editContextDom"; +import type { + EditContext, + EditContextCharacterBoundsUpdateEvent, + EditContextGlobal, + EditContextTextFormatUpdateEvent, + EditContextTextUpdateEvent, +} from "./editContextTypes"; +import { normalizeSelectionFormation } from "../utils/selectionFormation"; +import { handleFieldEditorKeyDown } from "./keyHandling"; +import { isHistoryTransactionOrigin } from "./historyOrigin"; +import { handleCopy, handleCut, handleClipboardPaste } from "./clipboard"; +import type { PasteImporters } from "../types/paste"; +import { applyListInputRule } from "./commands"; +import { isFieldEditorTextEditingKey } from "../utils/textEntryTarget"; +import { applyInlineInputRule } from "./inlineInputRules"; +import { applyInlineTextInput } from "./textInputPipeline"; +import type { + FieldEditorObserver, + FieldEditorTextChangeEvent, + FieldEditorTextLike, +} from "./crdt"; +import { EditContextBackendInput } from "./editContextBackendInput"; +import { + buildInlineDecorationsRenderSignature, + inlineDecorationsRequireFullReconcile, +} from "../utils/inlineDecorations"; + +export abstract class EditContextBackendSelection extends EditContextBackendInput { + protected handleSelectionChange = (): void => { + if (!this.element || !this.editContext) return; + const isApplyingSelection = + this.fieldEditor.getBackendSelectionApplicationDepth(); + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + isApplyingSelection, + ) + ) { + if (isApplyingSelection === 0) { + this.restoreDOMCaret(); + } + return; + } + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + if (!root) return; + + const mappedSelection = domSelectionToEditor(root); + if (!mappedSelection) return; + const normalizedSelection = normalizeSelectionFormation( + this.editor, + mappedSelection, + ); + + if (this.shouldIgnoreStaleCollapsedDomSelection(normalizedSelection)) { + this.restoreDOMCaret(); + return; + } + + if (normalizedSelection.type === "block") { + this.fieldEditor.deactivate(); + this.editor.setSelection({ + type: "block", + blockIds: normalizedSelection.blockIds, + }); + return; + } + + if ( + normalizedSelection.anchor.blockId !== + normalizedSelection.focus.blockId + ) { + this.fieldEditor.applyDocumentTextSelection( + normalizedSelection.anchor, + normalizedSelection.focus, + ); + return; + } + + if ( + normalizedSelection.anchor.blockId !== this.fieldEditor.focusBlockId + ) { + this.fieldEditor.activateTextSelection( + normalizedSelection.anchor.blockId, + normalizedSelection.anchor.offset, + normalizedSelection.focus.offset, + ); + return; + } + + const selection = this.element.ownerDocument?.getSelection(); + if (!selection?.rangeCount) return; + if (!this.element.contains(selection.anchorNode)) return; + if (!this.element.contains(selection.focusNode)) return; + + const offsets = getDirectionalSelectionOffsets(this.element); + if (!offsets) return; + const editorSelectionRange = this.resolveEditorSelectionRange( + normalizedSelection.anchor.blockId, + ); + if ( + editorSelectionRange && + offsets.anchor === offsets.focus && + (offsets.start !== editorSelectionRange.start || + offsets.end !== editorSelectionRange.end) + ) { + this.setEditContextSelection({ + blockId: normalizedSelection.anchor.blockId, + anchorOffset: editorSelectionRange.start, + focusOffset: editorSelectionRange.end, + }); + this.restoreDOMCaret(); + return; + } + const authoritativeSelection = this.getAuthoritativeTextInputSelection( + normalizedSelection.anchor.blockId, + ); + if ( + authoritativeSelection && + offsets.anchor === offsets.focus && + (offsets.anchor !== authoritativeSelection.anchorOffset || + offsets.focus !== authoritativeSelection.focusOffset) + ) { + this.setEditContextSelection(authoritativeSelection, { + source: "text-update", + }); + this.restoreDOMCaret(); + return; + } + + this.editContext.updateSelection(offsets.start, offsets.end); + const nextSelection = { + blockId: normalizedSelection.anchor.blockId, + anchorOffset: offsets.anchor, + focusOffset: offsets.focus, + }; + this.fieldEditor.setEditContextSelectionSnapshot(nextSelection); + this.fieldEditor.setBackendSelectionAuthority("user-dom", nextSelection); + this.fieldEditor.syncTextSelection( + normalizedSelection.anchor.blockId, + offsets.anchor, + offsets.focus, + ); + }; + + protected handleYTextChange = (event: FieldEditorTextChangeEvent): void => { + if (!this.editContext || !this.element || !this.ytext) return; + const isHistory = isHistoryTransactionOrigin(event.transaction?.origin); + if (isHistory) { + this.fieldEditor.clearBackendSelectionAuthority( + "edit-context-textupdate", + ); + const nextText = toEditContextText(this.ytext?.toString?.() ?? ""); + this.editContext.updateText( + 0, + this.editContext.text.length, + nextText, + ); + const clampedSelectionStart = Math.min( + this.editContext.selectionStart, + nextText.length, + ); + const clampedSelectionEnd = Math.min( + this.editContext.selectionEnd, + nextText.length, + ); + this.editContext.updateSelection( + clampedSelectionStart, + clampedSelectionEnd, + ); + const blockId = this.fieldEditor.focusBlockId; + this.fieldEditor.setEditContextSelectionSnapshot( + blockId + ? { + blockId, + anchorOffset: clampedSelectionStart, + focusOffset: clampedSelectionEnd, + } + : null, + ); + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.fieldEditor.notifyDomReconciled(blockId ?? undefined); + this.restoreDOMCaret(); + return; + } + + const inlineDecorations = this.getInlineDecorationsForBlock(); + if (inlineDecorationsRequireFullReconcile(inlineDecorations)) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations, + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + } else { + const applied = applyDeltaToDOM( + event.delta, + this.element, + this.editor.schema, + ); + if (!applied) { + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations, + }); + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + } + } + + if ( + shouldReplaceEditContextText( + event.delta, + this.editContext.text.length, + ) + ) { + const nextText = toEditContextText(this.ytext.toString()); + this.editContext.updateText( + 0, + this.editContext.text.length, + nextText, + ); + } else { + const delta = event.delta; + let offset = 0; + for (const entry of delta) { + if (entry.retain != null) { + offset += entry.retain; + } else if (typeof entry.insert === "string") { + this.editContext.updateText(offset, offset, entry.insert); + offset += entry.insert.length; + } else if (entry.delete != null) { + this.editContext.updateText( + offset, + offset + entry.delete, + "", + ); + } + } + } + + const pendingSelection = this.fieldEditor.focusBlockId + ? this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + this.fieldEditor.focusBlockId, + ) + : null; + if (pendingSelection) { + this.setEditContextSelection(pendingSelection, { + source: "text-update", + }); + } + this.restoreDOMCaret(); + }; + + protected handleDecorationsChange = (): void => { + if (!this.element || !this.ytext) { + return; + } + const nextInlineDecorationsSignature = this.getInlineDecorationsSignature(); + if (nextInlineDecorationsSignature === this.inlineDecorationsSignature) { + return; + } + fullReconcileToDOM(this.ytext, this.element, this.editor.schema, { + preserveSelection: true, + inlineDecorations: this.getInlineDecorationsForBlock(), + }); + this.inlineDecorationsSignature = nextInlineDecorationsSignature; + this.fieldEditor.notifyDomReconciled( + this.fieldEditor.focusBlockId ?? undefined, + ); + this.restoreDOMCaret(); + }; + + protected restoreDOMCaret(): void { + if (!this.editContext || !this.element) return; + + const root = this.element.closest( + "[data-pen-editor-root]", + ) as HTMLElement | null; + const selection = this.fieldEditor.selection; + const blockId = this.fieldEditor.focusBlockId; + const pendingSelection = + blockId != null + ? this.fieldEditor.getBackendSelectionAuthority( + "programmatic", + blockId, + ) + : null; + const authoritativeInputSelection = + blockId != null + ? this.fieldEditor.getBackendSelectionAuthority( + "edit-context-textupdate", + blockId, + ) + : null; + const editContextSelection = + this.fieldEditor.getEditContextSelectionSnapshot(blockId); + const editorSelection = + selection?.type === "text" && + blockId && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ? selection + : null; + const anchorOffset = + pendingSelection?.anchorOffset ?? + authoritativeInputSelection?.anchorOffset ?? + editorSelection?.anchor.offset ?? + editContextSelection?.anchorOffset ?? + null; + const focusOffset = + pendingSelection?.focusOffset ?? + authoritativeInputSelection?.focusOffset ?? + editorSelection?.focus.offset ?? + editContextSelection?.focusOffset ?? + null; + if (root && blockId && anchorOffset != null && focusOffset != null) { + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + editorSelectionToDOM( + root, + { blockId, offset: anchorOffset }, + { blockId, offset: focusOffset }, + ); + return; + } + + const start = this.editContext.selectionStart; + const end = this.editContext.selectionEnd; + + const anchorPoint = findTextPosition(this.element, start); + const focusPoint = + start === end ? anchorPoint : findTextPosition(this.element, end); + if (!anchorPoint || !focusPoint) return; + + const sel = this.element.ownerDocument?.getSelection(); + if (!sel) return; + + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(anchorPoint.node, anchorPoint.offset); + range.setEnd(focusPoint.node, focusPoint.offset); + sel.addRange(range); + } + + protected getInlineDecorationsForBlock(): readonly InlineDecoration[] { + const blockId = this.fieldEditor.focusBlockId; + if (!blockId) { + return []; + } + return this.editor + .getDecorations() + .forBlock(blockId) + .filter( + (decoration): decoration is InlineDecoration => + decoration.type === "inline", + ); + } + + protected getInlineDecorationsSignature(): string { + return buildInlineDecorationsRenderSignature( + this.getInlineDecorationsForBlock(), + ); + } + +} diff --git a/packages/rendering/dom/src/field-editor/editContextDom.ts b/packages/rendering/dom/src/field-editor/editContextDom.ts new file mode 100644 index 0000000..fc0d597 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextDom.ts @@ -0,0 +1,151 @@ +import type { FieldEditorTextChangeEvent } from "./crdt"; + +export type EditContextTextFormat = { + rangeStart: number; + rangeEnd: number; + underlineStyle?: string; + underlineThickness?: string; +}; + +const ZERO_WIDTH_SPACE = "\u200B"; + +export function applyEditContextTextFormats( + element: HTMLElement, + ranges: readonly EditContextTextFormat[], +): void { + for (const fmt of ranges) { + const { rangeStart, rangeEnd, underlineStyle, underlineThickness } = + fmt; + if (!underlineStyle) continue; + + const inlineEls = element.querySelectorAll("[data-pen-inline-content]"); + for (const el of inlineEls) { + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let offset = 0; + let textNode: Text | null; + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0; + const segStart = offset; + const segEnd = offset + len; + if (segEnd > rangeStart && segStart < rangeEnd) { + const parentEl = textNode.parentElement; + if (parentEl) { + parentEl.style.textDecoration = underlineStyle; + if (underlineThickness) { + parentEl.style.textDecorationThickness = + underlineThickness; + } + } + } + offset += len; + } + } + } +} + +export function buildEditContextCharacterBounds( + element: HTMLElement, + rangeStart: number, + rangeEnd: number, +): DOMRect[] { + const rects: DOMRect[] = []; + for (let index = rangeStart; index < rangeEnd; index += 1) { + rects.push(getCharacterRect(element, index)); + } + return rects; +} + +export function findTextPosition( + container: HTMLElement, + charOffset: number, +): { node: Node; offset: number } | null { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + ); + let remaining = charOffset; + let textNode: Text | null; + + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0; + if (remaining <= len) { + return { node: textNode, offset: remaining }; + } + remaining -= len; + } + + const last = container.lastChild; + if (last) { + return { node: last, offset: last.textContent?.length ?? 0 }; + } + return { node: container, offset: 0 }; +} + +export function isLogicallyEmptyText(text: string): boolean { + return text.length === 0 || text === ZERO_WIDTH_SPACE; +} + +export function toEditContextText(text: string): string { + return text === ZERO_WIDTH_SPACE ? "" : text; +} + +export function shouldReplaceEditContextText( + delta: FieldEditorTextChangeEvent["delta"], + editContextTextLength: number, +): boolean { + let offset = 0; + for (const entry of delta) { + if (entry.retain != null) { + offset += entry.retain; + if (offset > editContextTextLength) return true; + } else if (typeof entry.insert === "string") { + if (entry.insert === ZERO_WIDTH_SPACE) return true; + if (offset > editContextTextLength) return true; + offset += entry.insert.length; + } else if (entry.delete != null) { + if (offset + entry.delete > editContextTextLength) return true; + } + } + return false; +} + +export function isNavigationSelectionKey(event: KeyboardEvent): boolean { + return ( + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" || + event.key === "PageUp" || + event.key === "PageDown" + ); +} + +function getCharacterRect(element: HTMLElement, charOffset: number): DOMRect { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + ); + let remaining = charOffset; + let textNode: Text | null; + + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0; + if (remaining < len) { + const range = document.createRange(); + range.setStart(textNode, remaining); + range.setEnd(textNode, remaining + 1); + return range.getBoundingClientRect(); + } + remaining -= len; + } + + return element.getBoundingClientRect(); +} diff --git a/packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts b/packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts new file mode 100644 index 0000000..0745bae --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextSelectionAuthority.ts @@ -0,0 +1,303 @@ +export type EditContextSelection = { + blockId: string; + anchorOffset: number; + focusOffset: number; +}; + +export type EditContextRange = { + start: number; + end: number; +}; + +export type DirectionalSelectionOffsets = { + anchor: number; + focus: number; + start: number; + end: number; +}; + +export type KeyDownRangeResolution = { + range: EditContextRange; + nextSelection: EditContextSelection | null; + shouldSyncEditContextSelection: boolean; +}; + +export type TextUpdateRangeResolution = { + range: EditContextRange; + selection: EditContextSelection | null; +}; + +export function resolveEditContextTextUpdateRange(input: { + blockId: string; + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; + isLogicallyEmpty: boolean; + editorSelectionRange: EditContextRange | null; + programmaticInputRange: EditContextRange | null; + editContextSelection: EditContextSelection | null; + authoritativeTextInputSelection: EditContextSelection | null; + editorCaret: number | null; +}): TextUpdateRangeResolution { + const isCollapsedInsert = + input.text.length > 0 && + input.updateRangeStart === input.updateRangeEnd; + const editContextCaret = collapsedSelectionOffset( + input.editContextSelection, + input.blockId, + ); + const authoritativeInputCaret = collapsedSelectionOffset( + input.authoritativeTextInputSelection, + input.blockId, + ); + const trustedCaret = + authoritativeInputCaret ?? + (input.isLogicallyEmpty ? 0 : (editContextCaret ?? input.editorCaret)); + const shouldUseTrustedCaret = + isCollapsedInsert && + trustedCaret != null && + trustedCaret !== input.updateRangeStart; + const editorSelectionRange = input.editorSelectionRange; + const shouldUseEditorSelectionRange = + editorSelectionRange != null && + input.updateRangeStart === input.updateRangeEnd && + (input.updateRangeStart !== editorSelectionRange.start || + input.updateRangeEnd !== editorSelectionRange.end); + const shouldClampEmptyRange = + input.isLogicallyEmpty && authoritativeInputCaret == null; + const selectedEditorRange = shouldUseEditorSelectionRange + ? editorSelectionRange + : null; + const rangeStart = input.programmaticInputRange + ? input.programmaticInputRange.start + : selectedEditorRange + ? selectedEditorRange.start + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeStart; + const rangeEnd = input.programmaticInputRange + ? input.programmaticInputRange.end + : selectedEditorRange + ? selectedEditorRange.end + : shouldClampEmptyRange + ? 0 + : shouldUseTrustedCaret + ? trustedCaret + : input.updateRangeEnd; + const hasCollapsedEventSelection = + typeof input.selectionStart !== "number" || + typeof input.selectionEnd !== "number" || + input.selectionStart === input.selectionEnd; + const nextSelectionOffset = + input.text.length > 0 && hasCollapsedEventSelection + ? rangeStart + input.text.length + : null; + const anchorOffset = + nextSelectionOffset ?? + (typeof input.selectionStart === "number" + ? input.selectionStart + : null); + const focusOffset = + nextSelectionOffset ?? + (typeof input.selectionEnd === "number" ? input.selectionEnd : null); + + return { + range: { + start: rangeStart, + end: rangeEnd, + }, + selection: + anchorOffset != null && focusOffset != null + ? { + blockId: input.blockId, + anchorOffset, + focusOffset, + } + : null, + }; +} + +export function resolveEditContextKeyDownRange(input: { + blockId: string | null; + isTextEditingKey: boolean; + liveDomOffsets: DirectionalSelectionOffsets | null; + editContextRange: EditContextRange; + editorSelectionRange: EditContextRange | null; + programmaticInputRange: EditContextRange | null; + authoritativeTextInputSelection: EditContextSelection | null; + collapsedEditorSelectionRange: EditContextRange | null; + projectedTextSelection: EditContextSelection | null; + synchronizedEditContextRange: EditContextRange | null; +}): KeyDownRangeResolution { + if (!input.blockId) { + return { + range: input.liveDomOffsets + ? directionalSelectionToRange(input.liveDomOffsets) + : input.editContextRange, + nextSelection: null, + shouldSyncEditContextSelection: false, + }; + } + + if (input.programmaticInputRange) { + return { + range: input.programmaticInputRange, + nextSelection: rangeToSelection( + input.blockId, + input.programmaticInputRange, + ), + shouldSyncEditContextSelection: true, + }; + } + + const trustedKeyRange = resolveTrustedKeyDownRange(input); + if (trustedKeyRange) { + return { + range: trustedKeyRange, + nextSelection: rangeToSelection(input.blockId, trustedKeyRange), + shouldSyncEditContextSelection: true, + }; + } + + if ( + input.editorSelectionRange && + (!input.liveDomOffsets || + (input.liveDomOffsets.start === input.liveDomOffsets.end && + !rangesEqual(input.liveDomOffsets, input.editorSelectionRange))) + ) { + return { + range: input.editorSelectionRange, + nextSelection: rangeToSelection( + input.blockId, + input.editorSelectionRange, + ), + shouldSyncEditContextSelection: true, + }; + } + + if ( + input.liveDomOffsets && + shouldUseLiveDomSelection( + input.liveDomOffsets, + input.authoritativeTextInputSelection, + ) + ) { + return { + range: directionalSelectionToRange(input.liveDomOffsets), + nextSelection: { + blockId: input.blockId, + anchorOffset: input.liveDomOffsets.anchor, + focusOffset: input.liveDomOffsets.focus, + }, + shouldSyncEditContextSelection: true, + }; + } + + return { + range: input.liveDomOffsets + ? directionalSelectionToRange(input.liveDomOffsets) + : input.editContextRange, + nextSelection: null, + shouldSyncEditContextSelection: false, + }; +} + +export function collapsedSelectionOffset( + selection: EditContextSelection | null, + blockId: string, +): number | null { + if ( + selection?.blockId !== blockId || + selection.anchorOffset !== selection.focusOffset + ) { + return null; + } + return selection.focusOffset; +} + +export function selectionToRange( + selection: EditContextSelection, +): EditContextRange { + return { + start: Math.min(selection.anchorOffset, selection.focusOffset), + end: Math.max(selection.anchorOffset, selection.focusOffset), + }; +} + +export function directionalSelectionToRange( + selection: DirectionalSelectionOffsets, +): EditContextRange { + return { + start: selection.start, + end: selection.end, + }; +} + +export function rangeToSelection( + blockId: string, + range: EditContextRange, +): EditContextSelection { + return { + blockId, + anchorOffset: range.start, + focusOffset: range.end, + }; +} + +export function rangesEqual( + left: EditContextRange, + right: EditContextRange, +): boolean { + return left.start === right.start && left.end === right.end; +} + +function resolveTrustedKeyDownRange(input: { + isTextEditingKey: boolean; + editorSelectionRange: EditContextRange | null; + authoritativeTextInputSelection: EditContextSelection | null; + collapsedEditorSelectionRange: EditContextRange | null; + projectedTextSelection: EditContextSelection | null; + synchronizedEditContextRange: EditContextRange | null; +}): EditContextRange | null { + if (!input.isTextEditingKey) { + return null; + } + + if (input.editorSelectionRange) { + return input.editorSelectionRange; + } + + if (input.authoritativeTextInputSelection) { + return selectionToRange(input.authoritativeTextInputSelection); + } + + if (input.collapsedEditorSelectionRange) { + return input.collapsedEditorSelectionRange; + } + + if (input.projectedTextSelection) { + return selectionToRange(input.projectedTextSelection); + } + + if (input.synchronizedEditContextRange) { + return input.synchronizedEditContextRange; + } + + return null; +} + +function shouldUseLiveDomSelection( + liveDomOffsets: DirectionalSelectionOffsets, + authoritativeSelection: EditContextSelection | null, +): boolean { + return !( + authoritativeSelection && + liveDomOffsets.anchor === liveDomOffsets.focus && + (liveDomOffsets.anchor !== authoritativeSelection.anchorOffset || + liveDomOffsets.focus !== authoritativeSelection.focusOffset) + ); +} diff --git a/packages/rendering/dom/src/field-editor/editContextTypes.ts b/packages/rendering/dom/src/field-editor/editContextTypes.ts new file mode 100644 index 0000000..84e97f1 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/editContextTypes.ts @@ -0,0 +1,39 @@ +import type { EditContextTextFormat } from "./editContextDom"; + +export type EditContextTextUpdateEvent = Event & { + updateRangeStart: number; + updateRangeEnd: number; + text: string; + selectionStart?: number; + selectionEnd?: number; +}; + +export type EditContextTextFormatUpdateEvent = Event & { + getTextFormats?(): EditContextTextFormat[]; +}; + +export type EditContextCharacterBoundsUpdateEvent = Event & { + rangeStart: number; + rangeEnd: number; +}; + +export interface EditContext { + updateText(start: number, end: number, text: string): void; + updateSelection(start: number, end: number): void; + updateCharacterBounds(start: number, rects: DOMRect[]): void; + addEventListener(type: string, handler: (event: Event) => void): void; + removeEventListener(type: string, handler: (event: Event) => void): void; + readonly text: string; + readonly selectionStart: number; + readonly selectionEnd: number; +} + +export type EditContextConstructor = new (options?: { + text?: string; + selectionStart?: number; + selectionEnd?: number; +}) => EditContext; + +export type EditContextGlobal = typeof globalThis & { + EditContext?: EditContextConstructor; +}; diff --git a/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts b/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts index 0604baa..d6cf4cb 100644 --- a/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts +++ b/packages/rendering/dom/src/field-editor/expandedContentEditableBackend.ts @@ -1,8 +1,5 @@ import type { Editor } from "@pen/types"; -import { - editorSelectionToDOM, - domSelectionToEditor, -} from "./selectionBridge"; +import { editorSelectionToDOM, domSelectionToEditor } from "./selectionBridge"; import { handlePaste, handleCopy, handleCut } from "./clipboard"; import type { PasteImporters } from "../types/paste"; import type { FieldEditorInputController } from "./controller"; @@ -24,7 +21,6 @@ export class ExpandedContentEditableBackend { private element: HTMLElement | null = null; private editor: Editor; private fieldEditor: FieldEditorInputController; - private isApplyingSelection = 0; constructor(editor: Editor, fieldEditor: FieldEditorInputController) { this.editor = editor; @@ -35,6 +31,7 @@ export class ExpandedContentEditableBackend { this.element = element; element.contentEditable = "true"; element.tabIndex = -1; + this.fieldEditor.resetBackendSelectionAuthority(); element.addEventListener("beforeinput", this.handleBeforeInput); element.addEventListener("keydown", this.handleKeyDown); @@ -49,17 +46,21 @@ export class ExpandedContentEditableBackend { const selection = this.editor.selection; if (selection?.type === "text") { - this.isApplyingSelection++; - element.focus({ preventScroll: true }); + this.fieldEditor.applyBackendSelectionUntilNextFrame(); + if ( + !this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }) + ) { + return; + } editorSelectionToDOM(element, selection.anchor, selection.focus); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); return; } - element.focus({ preventScroll: true }); - this.isApplyingSelection = 0; + this.fieldEditor.requestDomFocus(element, "backend-activate", { + preventScroll: true, + }); } deactivate(): void { @@ -93,16 +94,17 @@ export class ExpandedContentEditableBackend { if (!this.element) return; const selection = this.editor.selection; if (selection?.type !== "text") return; - this.isApplyingSelection++; + this.fieldEditor.applyBackendSelectionUntilNextFrame(); editorSelectionToDOM(this.element, selection.anchor, selection.focus); - requestAnimationFrame(() => { - this.isApplyingSelection--; - }); } private handleSelectionChange = (): void => { if (!this.element) return; - if (!this.fieldEditor.shouldHandleDomSelectionChange(this.isApplyingSelection)) { + if ( + !this.fieldEditor.shouldHandleDomSelectionChange( + this.fieldEditor.getBackendSelectionApplicationDepth(), + ) + ) { return; } @@ -309,6 +311,9 @@ function getBlockText( }; }>(doc); return ( - ydoc.getMap("blocks").get(blockId)?.get("content") as FieldEditorTextLike | null - ) ?? null; + (ydoc + .getMap("blocks") + .get(blockId) + ?.get("content") as FieldEditorTextLike | null) ?? null + ); } diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts index 4e0378f..f93f782 100644 --- a/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts +++ b/packages/rendering/dom/src/field-editor/fieldEditorImpl.ts @@ -1,1365 +1,4 @@ -import type { - FieldEditor, - Editor, - BlockSchema, - HistoryAppliedEvent, - SelectionState, - Unsubscribe, - InputBackend, -} from "@pen/types"; -import { - DocumentRangeImpl, -} from "@pen/core"; -import { - hasFieldEditorSurface, - resolveFieldEditorInputMode, - usesInlineTextSelection, -} from "@pen/types"; -import { EditContextBackend } from "./editContextBackend"; -import { ContentEditableBackend } from "./contenteditableBackend"; -import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; -import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; -import { SessionReconciler } from "./sessionReconciler"; -import { classifySelectionSurface } from "./crossBlock"; -import { resolveMarksAtPosition } from "./markBoundary"; -import type { - ActiveCellCoord, - FieldEditorInputController, - FieldEditorSession, -} from "./controller"; -import { - getCellYText, - getResolvedYText, - resolveCellInlineElement, -} from "./contentResolution"; -import type { FieldEditorTextLike } from "./crdt"; -import { domSelectionToEditor, queryBlockElement, queryInlineElement } from "./selectionBridge"; -import { - getEditorBlockSelectionLength, - getEditorBlockSelectionRole, -} from "../utils/blockSelectionSemantics"; -import { - getEditorFlowCapability, - shouldForceBlockScopedSelectAll, -} from "../utils/flowCapabilities"; -import type { FieldEditorStoreSnapshot } from "./store"; -import { - resolveSelectAllBehavior, - type EditorSelectAllBehavior, -} from "../constants/selectAll"; +import type { FieldEditorSession } from "./controller"; +import { FieldEditorImplRuntime } from "./fieldEditorImplRuntime"; -type FieldEditorOptions = { - selectAllBehavior?: EditorSelectAllBehavior; -}; - -export class FieldEditorImpl implements FieldEditorSession { - private _focusBlockId: string | null = null; - private _activeBlockIds: string[] = []; - private _attachedElement: HTMLElement | null = null; - private _isEditing = false; - private _isFocused = false; - private _isComposing = false; - private _inputMode: "richtext" | "code" | "table" | "none" = "none"; - private _mode: "inactive" | "single" | "expanded" | "block" = "inactive"; - private _backend: InputBackend | null = null; - private _editor: Editor; - private _rootElement: HTMLElement | null = null; - private _activateListeners = new Set<(blockIds: string[]) => void>(); - private _deactivateListeners = new Set<(blockIds: string[]) => void>(); - private _storeListeners = new Set<() => void>(); - private _unsubscribeSelection: Unsubscribe | null = null; - private _unsubscribeHistoryApplied: Unsubscribe | null = null; - private _pendingMarks: Record = {}; - private _syncDomVersion = 0; - private _suppressNextDomSelectionProjection = false; - private _pointerSelectionDepth = 0; - private _pendingSelectionProjectionVersion: number | null = null; - private readonly _sessionReconciler: SessionReconciler; - private readonly _historySelectionCoordinator: HistorySelectionCoordinator; - private _selectAllBehavior: EditorSelectAllBehavior; - private _selectAllCycle: { - blockId: string; - scope: "cell" | "block" | "document"; - } | null = null; - private _preserveSelectAllCycle = false; - private _activeCellCoord: ActiveCellCoord | null = null; - - constructor(editor: Editor, options?: FieldEditorOptions) { - this._editor = editor; - this._selectAllBehavior = - options?.selectAllBehavior ?? resolveSelectAllBehavior("content-first"); - this._historySelectionCoordinator = new HistorySelectionCoordinator( - this._editor, - ); - this._unsubscribeSelection = this._editor.onSelectionChange( - (selection) => { - const preserveSelectAllCycle = - this._preserveSelectAllCycle || - this._selectionMatchesSelectAllCycle(selection); - this._preserveSelectAllCycle = false; - if (!preserveSelectAllCycle) { - this._selectAllCycle = null; - } - if ( - selection?.type !== "text" || - !selection.isCollapsed || - selection.isMultiBlock - ) { - this._clearPendingMarks(true); - } - const suppressSelectionSync = - this._consumeDomSelectionProjectionSuppression() || - this._shouldSuppressSelectionSync(); - this._recomputeSurfaceFromSelection({ - syncSelectionToBackend: !suppressSelectionSync, - }); - }, - ); - this._unsubscribeHistoryApplied = this._editor.onHistoryApplied((event) => { - this._handleHistoryApplied(event); - }); - this._sessionReconciler = new SessionReconciler(this._editor, { - getSnapshot: () => this.getSnapshot(), - getAttachedElement: () => this._attachedElement, - getInlineElement: (blockId) => this._resolveInlineElement(blockId), - getYText: (blockId) => this._getYText(blockId), - shouldPreserveSelection: () => - this._shouldProjectSelectionAfterReconcile(), - shouldProjectSelection: () => - this._shouldProjectSelectionAfterReconcile(), - projectSelection: () => this._syncDomSelectionOnce(), - }); - } - - get focusBlockId(): string | null { - return this._focusBlockId; - } - get activeBlockIds(): readonly string[] { - return this._activeBlockIds; - } - get isEditing(): boolean { - return this._isEditing; - } - get isFocused(): boolean { - return this._isFocused; - } - get isComposing(): boolean { - return this._isComposing; - } - get inputMode(): "richtext" | "code" | "table" | "none" { - return this._inputMode; - } - get selection(): SelectionState | null { - return this._isEditing ? this._editor.selection : null; - } - set selection(sel: SelectionState | null) { - this._editor.setSelection(sel); - this._emitStateChange(); - } - get activeCellCoord(): ActiveCellCoord | null { - return this._activeCellCoord; - } - - setSelectAllBehavior(behavior: EditorSelectAllBehavior): void { - if (this._selectAllBehavior === behavior) { - return; - } - this._selectAllBehavior = behavior; - this.resetSelectAllCycle(); - } - - // ── Lifecycle ───────────────────────────────────────────── - - activate(blockId: string): void { - if (this._focusBlockId === blockId) return; - this._startSession(blockId, { - stopCapturing: true, - syncSelectionToBackend: true, - attachImmediately: true, - }); - } - - activateCell(blockId: string, row: number, col: number): void { - this._activateCell(blockId, row, col); - this._trySyncCellBackend(0); - } - - activateCellFromElement( - blockId: string, - row: number, - col: number, - element: HTMLElement, - ): void { - this._activateCell(blockId, row, col); - this.attachElement(element); - this._placeCaretInCell(element); - } - - private _activateCell(blockId: string, row: number, col: number): void { - this._activeCellCoord = { blockId, row, col }; - if (!this._isEditing || this._focusBlockId !== blockId) { - this._startSession(blockId, { - stopCapturing: true, - syncSelectionToBackend: false, - attachImmediately: false, - }); - } - this._inputMode = "table"; - this._emitStateChange(); - } - - private _trySyncCellBackend(attempt: number): void { - const coord = this._activeCellCoord; - if (!coord) return; - - const ytext = this._getYTextForCell(coord.blockId, coord.row, coord.col); - if (!ytext) return; - - const root = this._findEditorRoot(); - if (!root) return; - - const cellEl = this._resolveCellElement( - coord.blockId, - coord.row, - coord.col, - root, - ); - - if (cellEl) { - this._attachedElement = null; - this.attachElement(cellEl); - this._placeCaretInCell(cellEl); - return; - } - - if (attempt < 3) { - requestAnimationFrame(() => this._trySyncCellBackend(attempt + 1)); - } - } - - private _placeCaretInCell(cellEl: HTMLElement): void { - cellEl.focus({ preventScroll: true }); - const selection = cellEl.ownerDocument?.getSelection(); - if (!selection) return; - - const range = cellEl.ownerDocument.createRange(); - range.selectNodeContents(cellEl); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } - - deactivate(): void { - this._deactivate({ restoreFocus: true }); - } - - selectAll(rootElement?: HTMLElement | null): boolean { - const activeCellElement = this._resolveActiveCellElement(rootElement); - if (activeCellElement) { - const activeCellBlockId = - this._activeCellCoord?.blockId ?? - this._resolveSelectAllBlockId(rootElement); - const shouldSelectCellContents = - !isDomSelectionCoveringElementContents(activeCellElement) || - this._selectAllCycle?.scope !== "cell" || - this._selectAllCycle.blockId !== activeCellBlockId; - if (shouldSelectCellContents) { - if ( - this._attachedElement !== activeCellElement || - !this._attachedElement?.isConnected - ) { - this.attachElement(activeCellElement); - } - selectElementContents(activeCellElement); - if (activeCellBlockId) { - this._recordSelectAllScope(activeCellBlockId, "cell"); - } - return true; - } - } - - if (this._selectAllBehavior === "document-first") { - const activeBlockId = this._resolveSelectAllBlockId(rootElement); - const activeCapability = activeBlockId - ? getEditorFlowCapability(this._editor, activeBlockId) - : null; - if ( - !shouldForceBlockScopedSelectAll( - this._editor.documentProfile, - activeCapability, - ) - ) { - return this._selectEntireDocument(); - } - } - - const blockId = this._resolveSelectAllBlockId(rootElement); - if (blockId) { - const blockLength = getEditorBlockSelectionLength(this._editor, blockId); - const blockRole = getEditorBlockSelectionRole(this._editor, blockId); - const shouldSelectDocument = - blockLength === 0 || - (this._selectAllCycle?.blockId === blockId && - this._selectAllCycle.scope === "block"); - const nextScope = - shouldSelectDocument ? "document" : "block"; - if (nextScope === "block") { - if (blockRole && blockRole !== "editable-inline") { - this.deactivate(); - this._editor.selectBlock(blockId); - this._recordSelectAllScope(blockId, "block"); - return true; - } - this.activateTextSelection(blockId, 0, blockLength); - this._recordSelectAllScope(blockId, "block"); - return true; - } - } - - return this._selectEntireDocument(blockId ?? null); - } - - private _selectEntireDocument(blockId?: string | null): boolean { - const range = getFullDocumentTextRange(this._editor); - if (!range) { - return true; - } - - if (!this._isEditing) { - this.activate(range.focusBlockId); - } - this._editor.selectTextRange(range.start, range.end); - this._recomputeSurfaceFromSelection(); - if (this._selectAllBehavior === "block-first") { - this._recordSelectAllScope(blockId ?? range.focusBlockId, "document"); - } - this._syncSelectionToDOM(); - return true; - } - - suspendForPointerSelection(): void { - if (this._isComposing) return; - this._deactivate({ restoreFocus: false }); - } - - beginPointerSelection(): void { - this._pointerSelectionDepth += 1; - } - - endPointerSelection(): void { - if (this._pointerSelectionDepth === 0) { - return; - } - this._pointerSelectionDepth -= 1; - } - - setComposing(composing: boolean): void { - if (this._isComposing === composing) return; - this._isComposing = composing; - this._emitStateChange(); - } - - private _deactivate(options: { restoreFocus: boolean }): void { - if (!this._isEditing) return; - - const blockIds = [...this._activeBlockIds]; - const focusTargetId = this._focusBlockId ?? blockIds[0] ?? null; - this._backend?.deactivate(); - this._backend = null; - this._attachedElement = null; - this._activeCellCoord = null; - - this._focusBlockId = null; - this._activeBlockIds = []; - this._isEditing = false; - this._isComposing = false; - this._historySelectionCoordinator.reset(); - this._suppressNextDomSelectionProjection = false; - this._pointerSelectionDepth = 0; - this._inputMode = "none"; - this._mode = "inactive"; - this._pendingMarks = {}; - - for (const cb of this._deactivateListeners) cb(blockIds); - if (options.restoreFocus) { - this._restoreFocusAfterDeactivate(focusTargetId); - } - this._emitStateChange(); - } - - focus(): void { - if (!this._isEditing || !this._focusBlockId) return; - const root = this._findEditorRoot(); - - if (!root) return; - - const blockEl = queryBlockElement(root, this._focusBlockId); - const inlineEl = blockEl?.querySelector( - "[data-pen-inline-content]", - ) as HTMLElement | null; - - if (!inlineEl) return; - - inlineEl.focus({ preventScroll: false }); - - const selection = root.ownerDocument?.getSelection(); - if (!selection) return; - - const range = root.ownerDocument.createRange(); - range.selectNodeContents(inlineEl); - range.collapse(false); - - selection.removeAllRanges(); - selection.addRange(range); - } - - blur(): void { - const root = this._findEditorRoot(); - if (!root) return; - const activeEl = root.ownerDocument?.activeElement; - if (activeEl instanceof HTMLElement && root.contains(activeEl)) { - activeEl.blur(); - } - } - - setRootElement(element: HTMLElement | null): void { - this._rootElement = element; - if (element && this._isEditing) { - this._syncActiveElement(false); - } - } - - setFocused(focused: boolean): void { - if (this._isFocused === focused) return; - this._isFocused = focused; - this._emitStateChange(); - } - - private _findEditorRoot(): HTMLElement | null { - if (!this._rootElement?.isConnected) return null; - return this._rootElement; - } - - private _findExpandedHost(): HTMLElement | null { - const root = this._findEditorRoot(); - if (!root) return null; - return root.querySelector( - "[data-pen-editor-blocks-host]", - ) as HTMLElement | null; - } - - attachElement(element: HTMLElement): void { - if (!this._focusBlockId) return; - if (this._attachedElement === element && this._backend) return; - this._backend?.deactivate(); - this._backend = this.createBackend(); - - const ytext = this._getYText(this._focusBlockId); - if (!ytext) return; - - this._backend.activate(element, ytext); - this._attachedElement = element; - } - - syncTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - if (!this._isEditing) return; - if (this._focusBlockId !== blockId) return; - - this.setTextSelection(blockId, anchorOffset, focusOffset); - } - - applyDocumentTextSelection( - anchor: { blockId: string; offset: number }, - focus: { blockId: string; offset: number }, - ): void { - this._suppressNextDomSelectionProjection = true; - - if (!this._isEditing || !this._focusBlockId) { - this._startSession(anchor.blockId, { - stopCapturing: false, - syncSelectionToBackend: false, - attachImmediately: false, - }); - } else { - const blockRange = new DocumentRangeImpl( - anchor, - focus, - this._editor.internals.doc, - ).blockRange; - if (!blockRange.includes(this._focusBlockId)) { - this._focusBlockId = anchor.blockId; - } - } - - this._editor.selectTextRange(anchor, focus); - this._emitStateChange(); - } - - applyDomTextSelection( - anchor: { blockId: string; offset: number }, - focus: { blockId: string; offset: number }, - options?: { - focusBlockId?: string; - }, - ): void { - if (anchor.blockId !== focus.blockId) { - this.applyDocumentTextSelection(anchor, focus); - return; - } - - this._suppressNextDomSelectionProjection = true; - - if ( - anchor.blockId === focus.blockId && - (!this._isEditing || this._focusBlockId !== anchor.blockId) - ) { - this._startSession(anchor.blockId, { - stopCapturing: false, - syncSelectionToBackend: false, - attachImmediately: false, - }); - } - - if (anchor.blockId === focus.blockId) { - this.setTextSelection(anchor.blockId, anchor.offset, focus.offset); - return; - } - - if (options?.focusBlockId) { - this._focusBlockId = options.focusBlockId; - } - this._editor.selectTextRange(anchor, focus); - this._emitStateChange(); - } - - shouldHandleDomSelectionChange(isApplyingSelection: number): boolean { - return ( - isApplyingSelection === 0 && - this._pointerSelectionDepth === 0 && - !this._shouldSuppressSelectionSync() - ); - } - - setTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - if (anchorOffset !== focusOffset) { - this._clearPendingMarks(true); - } - this._editor.selectText(blockId, anchorOffset, focusOffset); - this._emitStateChange(); - } - - activateTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - this._projectTextSelection(blockId, anchorOffset, focusOffset); - } - - collapseSelectionToFocus(): void { - const selection = this._editor.selection; - if (selection?.type !== "text") return; - - this._collapseAndProject(selection.focus); - } - - collapseSelectionToAnchor(): void { - const selection = this._editor.selection; - if (selection?.type !== "text") return; - - this._collapseAndProject(selection.anchor); - } - - collapseSelectionToPoint(point: { blockId: string; offset: number }): void { - this._collapseAndProject(point); - } - - private _collapseAndProject(point: { - blockId: string; - offset: number; - }): void { - this.setTextSelection(point.blockId, point.offset, point.offset); - - if (!this._isEditing || this._focusBlockId !== point.blockId) { - this.activate(point.blockId); - } - - this._syncDomSelectionOnce(); - } - - delegate(blockSchema: BlockSchema): boolean { - return hasFieldEditorSurface(blockSchema); - } - - getPendingMarks(): Readonly> { - return this._pendingMarks; - } - - clearPendingMarks(): void { - this._clearPendingMarks(); - } - - private _recordSelectAllScope( - blockId: string, - scope: "cell" | "block" | "document", - ): void { - this._preserveSelectAllCycle = true; - this._selectAllCycle = { blockId, scope }; - } - - resetSelectAllCycle(): void { - this._preserveSelectAllCycle = false; - this._selectAllCycle = null; - } - - private _syncSelectionToDOM(): void { - if (!this._isEditing) return; - this._syncDomSelectionOnce(); - } - - private _resolveSelectAllBlockId( - rootElement?: HTMLElement | null, - ): string | null { - const selection = this._editor.selection; - if (selection?.type === "text" && !selection.isMultiBlock) { - return selection.focus.blockId; - } - if ( - this._selectAllBehavior === "block-first" && - selection?.type === "block" && - selection.blockIds.length === 1 - ) { - return selection.blockIds[0] ?? null; - } - if (selection?.type === "cell") { - return selection.blockId; - } - - if (this._focusBlockId) { - return this._focusBlockId; - } - - const root = rootElement ?? this._findEditorRoot(); - if (!root) { - return null; - } - - const domSelection = domSelectionToEditor(root); - if ( - domSelection && - domSelection.anchor.blockId === domSelection.focus.blockId - ) { - return domSelection.focus.blockId; - } - - const activeElement = root.ownerDocument?.activeElement; - if (activeElement instanceof HTMLElement) { - return ( - activeElement - .closest("[data-block-id]") - ?.getAttribute("data-block-id") ?? null - ); - } - - return null; - } - - private _selectionMatchesSelectAllCycle( - selection: SelectionState | null, - ): boolean { - const cycle = this._selectAllCycle; - if (!cycle) { - return false; - } - - if (cycle.scope === "cell") { - return ( - selection?.type === "cell" && - selection.blockId === cycle.blockId - ); - } - - if (cycle.scope === "block") { - const blockLength = getEditorBlockSelectionLength( - this._editor, - cycle.blockId, - ); - const blockRole = getEditorBlockSelectionRole(this._editor, cycle.blockId); - if (blockRole && blockRole !== "editable-inline") { - return ( - selection?.type === "block" && - selection.blockIds.length === 1 && - selection.blockIds[0] === cycle.blockId - ); - } - - if (selection?.type !== "text") { - return false; - } - return ( - !selection.isMultiBlock && - selection.anchor.blockId === cycle.blockId && - selection.focus.blockId === cycle.blockId && - Math.min(selection.anchor.offset, selection.focus.offset) === - 0 && - Math.max(selection.anchor.offset, selection.focus.offset) === - blockLength - ); - } - - const range = getFullDocumentTextRange(this._editor); - if (!range) { - return false; - } - - if (selection?.type !== "text") { - return false; - } - - return ( - selection.isMultiBlock && - ((pointsEqual(selection.anchor, range.start) && - pointsEqual(selection.focus, range.end)) || - (pointsEqual(selection.anchor, range.end) && - pointsEqual(selection.focus, range.start))) - ); - } - - togglePendingMark(markType: string): boolean { - if (!this._isEditing || this._inputMode !== "richtext") return false; - - const baseMarks = this._resolveBaseInsertMarks(); - const baseValue = baseMarks[markType]; - const effectiveMarks = this._applyPendingMarks(baseMarks); - const nextValue = effectiveMarks[markType] != null ? null : true; - const nextPendingMarks = { ...this._pendingMarks }; - - if ((baseValue ?? null) === nextValue) { - delete nextPendingMarks[markType]; - } else { - nextPendingMarks[markType] = nextValue; - } - - this._pendingMarks = nextPendingMarks; - this._emitStateChange(); - return true; - } - - resolveInsertMarks( - ytext: FieldEditorTextLike, - offset: number, - ): Record | undefined { - const baseMarks = - resolveMarksAtPosition(ytext, offset, this._editor.schema) ?? {}; - const resolved = this._applyPendingMarks(baseMarks); - const insertMarks: Record = { ...resolved }; - - for (const [markType, value] of Object.entries(this._pendingMarks)) { - if (value == null && markType in baseMarks) { - insertMarks[markType] = null; - } - } - - return Object.keys(insertMarks).length > 0 ? insertMarks : undefined; - } - - // ── Cross-block expansion ──────────────────────────────── - - expandTo(blockId: string): void { - if (!this._isEditing || !this._focusBlockId) return; - - const selection = this._editor.selection; - const anchor = - selection?.type === "text" && - selection.blockRange.includes(this._focusBlockId) - ? selection.anchor - : { blockId: this._focusBlockId, offset: 0 }; - const doc = this._editor.documentState; - const activeIdx = doc.indexOf(this._focusBlockId); - const targetIdx = doc.indexOf(blockId); - if (activeIdx < 0 || targetIdx < 0) return; - - const targetOffset = - targetIdx >= activeIdx - ? (this._editor.getBlock(blockId)?.textContent().length ?? 0) - : 0; - - this._editor.selectTextRange(anchor, { - blockId, - offset: targetOffset, - }); - } - - contractToFocused(): void { - if (!this._isEditing || !this._focusBlockId) return; - - const selection = this._editor.selection; - if (selection?.type !== "text") return; - - this._editor.selectTextRange(selection.focus, selection.focus); - } - - // ── Events ─────────────────────────────────────────────── - - onActivate(cb: (blockIds: string[]) => void): Unsubscribe { - this._activateListeners.add(cb); - return () => this._activateListeners.delete(cb); - } - - onDeactivate(cb: (blockIds: string[]) => void): Unsubscribe { - this._deactivateListeners.add(cb); - return () => this._deactivateListeners.delete(cb); - } - - onSelectionChange(cb: (sel: SelectionState) => void): Unsubscribe { - return this._editor.onSelectionChange(cb); - } - - getSnapshot(): FieldEditorStoreSnapshot { - return { - focusBlockId: this._focusBlockId, - activeBlockIds: this._activeBlockIds, - isEditing: this._isEditing, - isFocused: this._isFocused, - isComposing: this._isComposing, - inputMode: this._inputMode, - mode: this._mode, - activeCellCoord: this._activeCellCoord, - }; - } - - subscribe(callback: () => void): Unsubscribe { - this._storeListeners.add(callback); - return () => this._storeListeners.delete(callback); - } - - destroy(): void { - this._unsubscribeSelection?.(); - this._unsubscribeSelection = null; - this._unsubscribeHistoryApplied?.(); - this._unsubscribeHistoryApplied = null; - this._sessionReconciler.destroy(); - this._deactivate({ restoreFocus: false }); - this._pointerSelectionDepth = 0; - this._activateListeners.clear(); - this._deactivateListeners.clear(); - this._storeListeners.clear(); - } - - // ── Internal ───────────────────────────────────────────── - - private createBackend(): InputBackend { - return new (this._resolveBackendClass())(this._editor, this); - } - - private _resolveBackendClass(): new ( - editor: Editor, - fieldEditor: FieldEditorInputController, - ) => InputBackend { - if (this._mode === "expanded") { - return ExpandedContentEditableBackend as unknown as new ( - editor: Editor, - fieldEditor: FieldEditorInputController, - ) => InputBackend; - } - if (this._activeCellCoord) { - return ContentEditableBackend; - } - if ( - "EditContext" in globalThis && - typeof (globalThis as typeof globalThis & { EditContext?: unknown }) - .EditContext === "function" - ) { - return EditContextBackend; - } - return ContentEditableBackend; - } - - private _syncActiveElement(focus: boolean): void { - if (!this._focusBlockId) return; - const inlineEl = this._resolveInlineElement(this._focusBlockId); - if (!inlineEl) return; - - this.attachElement(inlineEl); - if (focus) { - this.focus(); - } - } - - private _restoreFocusAfterDeactivate(blockId: string | null): void { - const root = this._findEditorRoot(); - if (!root) return; - - if (blockId) { - const blockEl = queryBlockElement(root, blockId); - if (blockEl) { - blockEl.focus({ preventScroll: true }); - return; - } - } - - root.focus({ preventScroll: true }); - } - - private _emitStateChange(): void { - for (const callback of this._storeListeners) { - callback(); - } - } - - private _consumeDomSelectionProjectionSuppression(): boolean { - const shouldSuppress = this._suppressNextDomSelectionProjection; - this._suppressNextDomSelectionProjection = false; - return shouldSuppress; - } - - private _resolveBaseInsertMarks(): Record { - const selection = this._editor.selection; - if (!this._focusBlockId || selection?.type !== "text") { - return {}; - } - - const blockId = selection.focus.blockId; - const ytext = this._getYText(blockId); - if (!ytext) return {}; - - return ( - resolveMarksAtPosition( - ytext, - selection.focus.offset, - this._editor.schema, - ) ?? {} - ); - } - - private _applyPendingMarks( - baseMarks: Record, - ): Record { - const nextMarks = { ...baseMarks }; - for (const [markType, value] of Object.entries(this._pendingMarks)) { - if (value == null) { - delete nextMarks[markType]; - } else { - nextMarks[markType] = value; - } - } - return nextMarks; - } - - private _clearPendingMarks(silent = false): void { - if (Object.keys(this._pendingMarks).length === 0) return; - this._pendingMarks = {}; - if (!silent) { - this._emitStateChange(); - } - } - - private _recomputeSurfaceFromSelection(options?: { - syncSelectionToBackend?: boolean; - }): void { - const surface = classifySelectionSurface( - this._editor, - this._editor.selection, - this._focusBlockId, - this._isEditing, - ); - this._updateSurfaceState(surface.mode, surface.blockIds); - if (options?.syncSelectionToBackend ?? true) { - this._backend?.updateSelection(null); - } - } - - private _updateSurfaceState( - mode: "inactive" | "single" | "expanded" | "block", - blockIds: string[], - ): void { - const modeChanged = this._mode !== mode; - const blockIdsChanged = !areBlockIdsEqual( - this._activeBlockIds, - blockIds, - ); - if (!modeChanged && !blockIdsChanged) return; - this._mode = mode; - this._activeBlockIds = blockIds; - this._syncBackendForSurfaceMode(); - - if (this._isEditing && blockIdsChanged) { - for (const cb of this._activateListeners) cb([...blockIds]); - } - - this._emitStateChange(); - } - - private _syncBackendForSurfaceMode(): void { - if (!this._isEditing || !this._focusBlockId) return; - const NextBackendClass = this._resolveBackendClass(); - if (this._backend?.constructor === NextBackendClass) { - return; - } - - this._backend?.deactivate(); - this._backend = new NextBackendClass(this._editor, this); - - if (this._mode === "expanded") { - const expandedHost = this._findExpandedHost(); - this._attachedElement = null; - if (expandedHost) { - this.attachElement(expandedHost); - } - return; - } - - if (this._mode === "single") { - const inlineEl = this._resolveInlineElement(this._focusBlockId); - if (inlineEl) { - this._attachedElement = null; - this.attachElement(inlineEl); - return; - } - } - - if (!this._attachedElement) return; - - const ytext = this._getYText(this._focusBlockId); - if (!ytext) return; - - this._backend.activate(this._attachedElement, ytext); - } - - private _startSession( - blockId: string, - options: { - stopCapturing: boolean; - syncSelectionToBackend: boolean; - attachImmediately: boolean; - }, - ): boolean { - if (this._isEditing) this._deactivate({ restoreFocus: false }); - - const block = this._editor.getBlock(blockId); - if (!block) return false; - - const schema = this._editor.schema.resolve(block.type); - if (schema?.fieldEditor === "none") return false; - - this._focusBlockId = blockId; - this._activeBlockIds = [blockId]; - this._isEditing = true; - this._isComposing = false; - this._mode = "single"; - this._pendingMarks = {}; - - if (options.stopCapturing) { - this._editor.undoManager.stopCapturing(); - } - - this._inputMode = resolveInputMode(schema); - this._backend = this.createBackend(); - this._attachedElement = null; - if (options.attachImmediately) { - this._syncActiveElement(false); - } - this._recomputeSurfaceFromSelection({ - syncSelectionToBackend: options.syncSelectionToBackend, - }); - - for (const cb of this._activateListeners) cb([...this._activeBlockIds]); - this._emitStateChange(); - return true; - } - - private _handleHistoryApplied( - event: HistoryAppliedEvent, - ): void { - const selection = event.selection; - const nextFocusBlockId = - event.focusBlockId ?? - (selection?.type === "text" ? selection.focus.blockId : null); - if (selection?.type !== "text") { - if (this._isEditing) { - this._deactivate({ restoreFocus: false }); - } - return; - } - - if (!this._isEditing) { - return; - } - - if (nextFocusBlockId) { - this._focusBlockId = nextFocusBlockId; - } - - this._historySelectionCoordinator.beginDeferredProjection(event.requestId); - - this._recomputeSurfaceFromSelection({ - syncSelectionToBackend: false, - }); - } - - private _attachedElementOwnsFocus(): boolean { - if (!this._attachedElement) { - return false; - } - const activeElement = this._attachedElement.ownerDocument?.activeElement; - return activeElement instanceof Node - ? this._attachedElement.contains(activeElement) - : false; - } - - private _shouldProjectSelectionAfterReconcile(): boolean { - if (!this._attachedElement) { - return false; - } - - const ownerDocument = this._attachedElement.ownerDocument; - const activeElement = ownerDocument?.activeElement; - if (!(activeElement instanceof Node)) { - return true; - } - if (activeElement === ownerDocument?.body) { - return true; - } - - const root = this._findEditorRoot(); - if (!root || !root.contains(activeElement)) { - return true; - } - - return this._attachedElement.contains(activeElement); - } - - private _resolveInlineElement(blockId: string): HTMLElement | null { - const root = this._findEditorRoot(); - if (!root) return null; - const activeCell = this._activeCellCoord; - if (activeCell?.blockId === blockId) { - return this._resolveCellElement( - activeCell.blockId, - activeCell.row, - activeCell.col, - root, - ); - } - return queryInlineElement(root, blockId); - } - - private _projectTextSelection( - blockId: string, - anchorOffset: number, - focusOffset: number, - ): void { - this.setTextSelection(blockId, anchorOffset, focusOffset); - - if (!this._isEditing || this._focusBlockId !== blockId) { - this.activate(blockId); - } - - this._syncDomSelectionOnce(); - } - - private _syncDomSelectionOnce( - remainingAttempts = 4, - version?: number, - ): void { - if (version === undefined) { - version = ++this._syncDomVersion; - this._pendingSelectionProjectionVersion = version; - } - const v = version; - requestAnimationFrame(() => { - if (!this._isEditing || this._syncDomVersion !== v) return; - - let projected = false; - const pendingProjectionRequestId = - this._historySelectionCoordinator.getPendingProjectionRequestId(); - - if (this._mode === "expanded") { - const expandedHost = this._findExpandedHost(); - if (expandedHost) { - if ( - this._attachedElement !== expandedHost || - !this._attachedElement?.isConnected - ) { - this.attachElement(expandedHost); - } - expandedHost.focus({ preventScroll: true }); - this._backend?.updateSelection(null); - projected = true; - } - } else if (this._focusBlockId) { - const inlineEl = this._resolveInlineElement(this._focusBlockId); - if (inlineEl) { - if ( - this._attachedElement !== inlineEl || - !this._attachedElement || - !this._attachedElement.isConnected - ) { - this.attachElement(inlineEl); - } - inlineEl.focus({ preventScroll: true }); - this._backend?.updateSelection(null); - projected = true; - } - } - - if (projected) { - requestAnimationFrame(() => { - if (this._syncDomVersion === v) { - if (this._pendingSelectionProjectionVersion === v) { - this._pendingSelectionProjectionVersion = null; - } - this._historySelectionCoordinator.completeDeferredProjection( - pendingProjectionRequestId, - ); - } - }); - } - - if (!projected && remainingAttempts > 0) { - this._syncDomSelectionOnce(remainingAttempts - 1, v); - } else if (!projected) { - if (this._pendingSelectionProjectionVersion === v) { - this._pendingSelectionProjectionVersion = null; - } - this._historySelectionCoordinator.cancelDeferredProjection(); - } - }); - } - - private _shouldSuppressSelectionSync(): boolean { - return ( - this._historySelectionCoordinator.shouldSuppressSelectionSync() || - this._pendingSelectionProjectionVersion !== null - ); - } - - private _getYText(blockId: string): FieldEditorTextLike | null { - return getResolvedYText(this._editor, blockId, this._activeCellCoord); - } - - private _getYTextForCell( - blockId: string, - row: number, - col: number, - ): FieldEditorTextLike | null { - return getCellYText(this._editor, blockId, row, col); - } - - private _resolveCellElement( - blockId: string, - row: number, - col: number, - root?: HTMLElement | null, - ): HTMLElement | null { - return resolveCellInlineElement(blockId, row, col, root ?? this._findEditorRoot()); - } - - private _resolveActiveCellElement( - rootElement?: HTMLElement | null, - ): HTMLElement | null { - const coord = this._activeCellCoord; - if (!coord) return null; - return this._resolveCellElement( - coord.blockId, - coord.row, - coord.col, - rootElement ?? undefined, - ); - } -} - -function resolveInputMode( - schema?: BlockSchema | null, -): "richtext" | "code" | "table" | "none" { - return resolveFieldEditorInputMode(schema); -} - -function selectElementContents(element: HTMLElement): void { - element.focus({ preventScroll: true }); - const selection = element.ownerDocument?.getSelection(); - if (!selection) return; - - const range = element.ownerDocument.createRange(); - range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); -} - -function isDomSelectionCoveringElementContents(element: HTMLElement): boolean { - const selection = element.ownerDocument?.getSelection(); - if (!selection || selection.rangeCount === 0) { - return false; - } - - const range = selection.getRangeAt(0); - if ( - !element.contains(range.startContainer) || - !element.contains(range.endContainer) - ) { - return false; - } - - const fullRange = element.ownerDocument.createRange(); - fullRange.selectNodeContents(element); - return ( - range.compareBoundaryPoints(Range.START_TO_START, fullRange) === 0 && - range.compareBoundaryPoints(Range.END_TO_END, fullRange) === 0 - ); -} - -function areBlockIdsEqual( - left: readonly string[], - right: readonly string[], -): boolean { - if (left.length !== right.length) return false; - for (let i = 0; i < left.length; i++) { - if (left[i] !== right[i]) return false; - } - return true; -} - -function getFullDocumentTextRange(editor: Editor): { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - focusBlockId: string; -} | null { - const blockOrder = editor.documentState.blockOrder; - const firstBlockId = blockOrder[0]; - const lastBlockId = blockOrder[blockOrder.length - 1]; - if (!firstBlockId || !lastBlockId) { - return null; - } - - const focusBlockId = - blockOrder.find((blockId) => { - const block = editor.getBlock(blockId); - if (!block) return false; - const schema = editor.schema.resolve(block.type); - return usesInlineTextSelection(schema); - }) ?? firstBlockId; - - return { - start: { blockId: firstBlockId, offset: 0 }, - end: { - blockId: lastBlockId, - offset: getEditorBlockSelectionLength(editor, lastBlockId), - }, - focusBlockId, - }; -} - -function pointsEqual( - left: { blockId: string; offset: number }, - right: { blockId: string; offset: number }, -): boolean { - return left.blockId === right.blockId && left.offset === right.offset; -} +export class FieldEditorImpl extends FieldEditorImplRuntime implements FieldEditorSession {} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts new file mode 100644 index 0000000..833a404 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplCore.ts @@ -0,0 +1,313 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; + +export type FieldEditorOptions = { + selectAllBehavior?: EditorSelectAllBehavior; + focusPolicy?: PenFocusPolicy; +}; + +export abstract class FieldEditorImplCore { + protected _focusBlockId: string | null = null; + protected _activeBlockIds: string[] = []; + protected _attachedElement: HTMLElement | null = null; + protected _isEditing = false; + protected _isFocused = false; + protected _isComposing = false; + protected _suppressNextBackendActivationFocus = false; + protected _inputMode: "richtext" | "code" | "table" | "none" = "none"; + protected _mode: "inactive" | "single" | "expanded" | "block" = "inactive"; + protected _editor: Editor; + protected _rootElement: HTMLElement | null = null; + protected _activateListeners = new Set<(blockIds: string[]) => void>(); + protected _deactivateListeners = new Set<(blockIds: string[]) => void>(); + protected _storeListeners = new Set<() => void>(); + protected _unsubscribeSelection: Unsubscribe | null = null; + protected _unsubscribeHistoryApplied: Unsubscribe | null = null; + protected _domSyncVersion = 0; + protected readonly _sessionReconciler: SessionReconciler; + protected readonly _backendLifecycle: BackendLifecycleController; + protected readonly _focusController: FocusController; + protected readonly _cellEditingController: CellEditingController; + protected readonly _historySelectionCoordinator: HistorySelectionCoordinator; + protected readonly _pendingMarkController: PendingMarkController; + protected readonly _selectAllController: SelectAllController; + protected readonly _selectionCoordinator: FieldEditorSelectionCoordinator; + + constructor(editor: Editor, options?: FieldEditorOptions) { + this._editor = editor; + this._backendLifecycle = new BackendLifecycleController( + this._editor, + this as unknown as FieldEditorInputController, + ); + this._selectAllController = new SelectAllController( + options?.selectAllBehavior, + ); + this._focusController = new FocusController({ + editor: this._editor, + getRootElement: () => this._findEditorRoot(), + getFocusBlockId: () => this._focusBlockId, + getAttachedElement: () => this._attachedElement, + }); + this._focusController.setFocusPolicy(options?.focusPolicy); + this._cellEditingController = new CellEditingController({ + getRootElement: () => this._findEditorRoot(), + getYTextForCell: (blockId, row, col) => + this._getYTextForCell(blockId, row, col), + attachElement: (element) => this.attachElement(element), + requestDomFocus: (target, reason, focusOptions, policyOptions) => + this.requestDomFocus( + target, + reason, + focusOptions, + policyOptions, + ), + }); + this._pendingMarkController = new PendingMarkController({ + editor: this._editor, + getFocusBlockId: () => this._focusBlockId, + getYText: (blockId) => this._getYText(blockId), + emitStateChange: () => this._emitStateChange(), + }); + this._historySelectionCoordinator = new HistorySelectionCoordinator( + this._editor, + ); + this._selectionCoordinator = new FieldEditorSelectionCoordinator({ + historySelectionCoordinator: this._historySelectionCoordinator, + isEditing: () => this._isEditing, + getMode: () => this._mode, + getFocusBlockId: () => this._focusBlockId, + getAttachedElement: () => this._attachedElement, + getRootElement: () => this._findEditorRoot(), + findExpandedHost: () => this._findExpandedHost(), + resolveInlineElement: (blockId) => + this._resolveInlineElement(blockId), + attachElement: (element, focusOptions) => + this.attachElement(element, focusOptions), + requestDomFocus: (target, reason, focusOptions, policyOptions) => + this.requestDomFocus( + target, + reason, + focusOptions, + policyOptions, + ), + updateBackendSelection: () => { + this._backendLifecycle.updateSelection(null); + }, + setTextSelection: (blockId, anchorOffset, focusOffset) => + this.setTextSelection(blockId, anchorOffset, focusOffset), + activate: (blockId) => this.activate(blockId), + emitSelectionProjected: () => { + this._emitFocusLifecycle({ + type: "selection-projected", + editor: this._editor, + blockId: this._focusBlockId, + }); + }, + }); + this._unsubscribeSelection = this._editor.onSelectionChange( + (selection) => { + this._selectAllController.consumeShouldPreserveCycle( + selection, + (cycle, nextSelection) => + this._selectionMatchesSelectAllCycle( + cycle, + nextSelection, + ), + ); + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { + this._pendingMarkController.clear(true); + } + const suppressSelectionSync = + this._selectionCoordinator.consumeDomSelectionProjectionSuppression() || + this._selectionCoordinator.shouldSuppressSelectionSync(); + this._recomputeSurfaceFromSelection({ + syncSelectionToBackend: !suppressSelectionSync, + }); + }, + ); + this._unsubscribeHistoryApplied = this._editor.onHistoryApplied( + (event) => { + this._handleHistoryApplied(event); + }, + ); + this._sessionReconciler = new SessionReconciler(this._editor, { + getSnapshot: () => this.getSnapshot(), + getAttachedElement: () => this._attachedElement, + getInlineElement: (blockId) => this._resolveInlineElement(blockId), + getYText: (blockId) => this._getYText(blockId), + shouldPreserveSelection: () => + this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), + shouldProjectSelection: () => + this._selectionCoordinator.shouldProjectSelectionAfterReconcile(), + projectSelection: () => + this._selectionCoordinator.syncDomSelectionOnce(), + notifyDomReconciled: (blockId) => this.notifyDomReconciled(blockId), + }); + } + + get focusBlockId(): string | null { + return this._focusBlockId; + } + get activeBlockIds(): readonly string[] { + return this._activeBlockIds; + } + get isEditing(): boolean { + return this._isEditing; + } + get isFocused(): boolean { + return this._isFocused; + } + get isComposing(): boolean { + return this._isComposing; + } + get inputMode(): "richtext" | "code" | "table" | "none" { + return this._inputMode; + } + get selection(): SelectionState | null { + return this._isEditing ? this._editor.selection : null; + } + set selection(sel: SelectionState | null) { + this._editor.setSelection(sel); + this._emitStateChange(); + } + get activeCellCoord(): ActiveCellCoord | null { + return this._cellEditingController.activeCellCoord; + } + + setSelectAllBehavior(behavior: EditorSelectAllBehavior): void { + this._selectAllController.setBehavior(behavior); + } + + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { + this._focusController.setFocusPolicy(focusPolicy); + } + + + abstract activate(blockId: string): void; + abstract attachElement( + element: HTMLElement, + options?: PenFieldEditorFocusOptions, + ): boolean; + abstract requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ): boolean; + abstract setTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void; + abstract getSnapshot(): FieldEditorStoreSnapshot; + + abstract commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void; + abstract waitForAttachment(blockId?: string | null): Promise; + protected abstract _startSession( + blockId: string, + options: { + stopCapturing: boolean; + syncSelectionToBackend: boolean; + attachImmediately: boolean; + }, + ): boolean; + protected abstract _resolveActiveCellElement( + rootElement?: HTMLElement | null, + ): HTMLElement | null; + protected abstract _resolveSelectAllBlockId( + rootElement?: HTMLElement | null, + ): string | null; + protected abstract _selectElementContents(element: HTMLElement): void; + protected abstract _syncSelectionToDOM(): void; + protected abstract _restoreFocusAfterDeactivate( + blockId: string | null, + ): void; + protected abstract _syncActiveElement(focus: boolean): void; + protected abstract _resolveBackendClass(): InputBackendConstructor; + abstract notifyDomReconciled(blockId?: string): void; + protected abstract _findEditorRoot(): HTMLElement | null; + protected abstract _findExpandedHost(): HTMLElement | null; + protected abstract _getYTextForCell( + blockId: string, + row: number, + col: number, + ): FieldEditorTextLike | null; + protected abstract _getYText(blockId: string): FieldEditorTextLike | null; + protected abstract _resolveInlineElement(blockId: string): HTMLElement | null; + protected abstract _emitStateChange(): void; + protected abstract _emitFocusLifecycle(event: PenFocusLifecycleEvent): void; + protected abstract _selectionMatchesSelectAllCycle( + cycle: { blockId: string; scope: "cell" | "block" | "document" }, + selection: SelectionState | null, + ): boolean; + protected abstract _recomputeSurfaceFromSelection(options?: { + syncSelectionToBackend?: boolean; + }): void; + protected abstract _handleHistoryApplied(event: HistoryAppliedEvent): void; +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts new file mode 100644 index 0000000..de5c9f2 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplHelpers.ts @@ -0,0 +1,79 @@ +import type { BlockSchema, Editor } from "@pen/types"; +import { usesInlineTextSelection, resolveFieldEditorInputMode } from "@pen/types"; +import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; + +export function resolveInputMode( + schema?: BlockSchema | null, +): "richtext" | "code" | "table" | "none" { + return resolveFieldEditorInputMode(schema); +} + +export function isDomSelectionCoveringElementContents(element: HTMLElement): boolean { + const selection = element.ownerDocument?.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + if ( + !element.contains(range.startContainer) || + !element.contains(range.endContainer) + ) { + return false; + } + + const fullRange = element.ownerDocument.createRange(); + fullRange.selectNodeContents(element); + return ( + range.compareBoundaryPoints(Range.START_TO_START, fullRange) === 0 && + range.compareBoundaryPoints(Range.END_TO_END, fullRange) === 0 + ); +} + +export function areBlockIdsEqual( + left: readonly string[], + right: readonly string[], +): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false; + } + return true; +} + +export function getFullDocumentTextRange(editor: Editor): { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + focusBlockId: string; +} | null { + const blockOrder = editor.documentState.blockOrder; + const firstBlockId = blockOrder[0]; + const lastBlockId = blockOrder[blockOrder.length - 1]; + if (!firstBlockId || !lastBlockId) { + return null; + } + + const focusBlockId = + blockOrder.find((blockId) => { + const block = editor.getBlock(blockId); + if (!block) return false; + const schema = editor.schema.resolve(block.type); + return usesInlineTextSelection(schema); + }) ?? firstBlockId; + + return { + start: { blockId: firstBlockId, offset: 0 }, + end: { + blockId: lastBlockId, + offset: getEditorBlockSelectionLength(editor, lastBlockId), + }, + focusBlockId, + }; +} + +export function pointsEqual( + left: { blockId: string; offset: number }, + right: { blockId: string; offset: number }, +): boolean { + return left.blockId === right.blockId && left.offset === right.offset; +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts new file mode 100644 index 0000000..3e5fad6 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplLifecycle.ts @@ -0,0 +1,417 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { DocumentRangeImpl } from "@pen/core"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import { FieldEditorImplCore } from "./fieldEditorImplCore"; +import { + getFullDocumentTextRange, + isDomSelectionCoveringElementContents, +} from "./fieldEditorImplHelpers"; + +export abstract class FieldEditorImplLifecycle extends FieldEditorImplCore { + activate(blockId: string): void { + if (this._focusBlockId === blockId) return; + this._startSession(blockId, { + stopCapturing: true, + syncSelectionToBackend: true, + attachImmediately: true, + }); + } + + activateCell(blockId: string, row: number, col: number): void { + this._activateCell(blockId, row, col); + this._attachedElement = null; + this._cellEditingController.trySyncBackend(); + } + + activateCellFromElement( + blockId: string, + row: number, + col: number, + element: HTMLElement, + ): void { + this._activateCell(blockId, row, col); + this.attachElement(element); + this._cellEditingController.placeCaretInCell(element); + } + + protected _activateCell(blockId: string, row: number, col: number): void { + this._cellEditingController.setActiveCell(blockId, row, col); + if (!this._isEditing || this._focusBlockId !== blockId) { + this._startSession(blockId, { + stopCapturing: true, + syncSelectionToBackend: false, + attachImmediately: false, + }); + } + this._inputMode = "table"; + this._emitStateChange(); + } + + deactivate(): void { + this._deactivate({ restoreFocus: true }); + } + + selectAll(rootElement?: HTMLElement | null): boolean { + const activeCellElement = this._resolveActiveCellElement(rootElement); + if (activeCellElement) { + const activeCellBlockId = + this._cellEditingController.activeCellCoord?.blockId ?? + this._resolveSelectAllBlockId(rootElement); + const shouldSelectCellContents = + !isDomSelectionCoveringElementContents(activeCellElement) || + !this._selectAllController.hasScope(activeCellBlockId, "cell"); + if (shouldSelectCellContents) { + if ( + this._attachedElement !== activeCellElement || + !this._attachedElement?.isConnected + ) { + this.attachElement(activeCellElement); + } + this._selectElementContents(activeCellElement); + if (activeCellBlockId) { + this._selectAllController.recordScope( + activeCellBlockId, + "cell", + ); + } + return true; + } + } + + if (this._selectAllController.getBehavior() === "document-first") { + const activeBlockId = this._resolveSelectAllBlockId(rootElement); + const activeCapability = activeBlockId + ? getEditorFlowCapability(this._editor, activeBlockId) + : null; + if ( + !shouldForceBlockScopedSelectAll( + this._editor.documentProfile, + activeCapability, + ) + ) { + return this._selectEntireDocument(); + } + } + + const blockId = this._resolveSelectAllBlockId(rootElement); + if (blockId) { + const blockLength = getEditorBlockSelectionLength( + this._editor, + blockId, + ); + const blockRole = getEditorBlockSelectionRole( + this._editor, + blockId, + ); + const shouldSelectDocument = + blockLength === 0 || + this._selectAllController.hasScope(blockId, "block"); + const nextScope = shouldSelectDocument ? "document" : "block"; + if (nextScope === "block") { + if (blockRole && blockRole !== "editable-inline") { + this.deactivate(); + this._editor.selectBlock(blockId); + this._selectAllController.recordScope(blockId, "block"); + return true; + } + this.commitProgrammaticTextSelection(blockId, 0, blockLength); + this._selectAllController.recordScope(blockId, "block"); + return true; + } + } + + return this._selectEntireDocument(blockId ?? null); + } + + protected _selectEntireDocument(blockId?: string | null): boolean { + const range = getFullDocumentTextRange(this._editor); + if (!range) { + return true; + } + + if (range.start.blockId === range.end.blockId) { + this.commitProgrammaticTextSelection( + range.start.blockId, + range.start.offset, + range.end.offset, + ); + } else { + if (!this._isEditing) { + this.activate(range.focusBlockId); + } + this._editor.selectTextRange(range.start, range.end); + } + this._recomputeSurfaceFromSelection(); + if (this._selectAllController.getBehavior() === "block-first") { + this._selectAllController.recordScope( + blockId ?? range.focusBlockId, + "document", + ); + } + this._syncSelectionToDOM(); + return true; + } + + suspendForPointerSelection(): void { + if (this._isComposing) return; + this._deactivate({ restoreFocus: false }); + } + + beginPointerSelection(): void { + this._selectionCoordinator.beginPointerSelection(); + } + + endPointerSelection(): void { + this._selectionCoordinator.endPointerSelection(); + } + + setComposing(composing: boolean): void { + if (this._isComposing === composing) return; + this._isComposing = composing; + this._emitStateChange(); + } + + protected _deactivate(options: { restoreFocus: boolean }): void { + if (!this._isEditing) return; + + const blockIds = [...this._activeBlockIds]; + const focusTargetId = this._focusBlockId ?? blockIds[0] ?? null; + this._backendLifecycle.deactivate(); + this._attachedElement = null; + this._cellEditingController.clear(); + + this._focusBlockId = null; + this._activeBlockIds = []; + this._isEditing = false; + this._isComposing = false; + this._historySelectionCoordinator.reset(); + this._selectionCoordinator.reset(); + this._inputMode = "none"; + this._mode = "inactive"; + this._pendingMarkController.reset(); + + for (const cb of this._deactivateListeners) cb(blockIds); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [], + isEditing: false, + }); + if (options.restoreFocus) { + this._restoreFocusAfterDeactivate(focusTargetId); + } + this._emitStateChange(); + } + + focus(options: PenFieldEditorFocusOptions = {}): boolean { + if (!this._isEditing || !this._focusBlockId) return false; + const root = this._findEditorRoot(); + + if (!root) return false; + + const blockEl = queryBlockElement(root, this._focusBlockId); + const inlineEl = blockEl?.querySelector( + "[data-pen-inline-content]", + ) as HTMLElement | null; + + if (!inlineEl) return false; + + const selection = this._editor.selection; + if ( + !this.requestDomFocus( + inlineEl, + "activate", + { + preventScroll: false, + }, + options, + ) + ) { + return false; + } + + if ( + selection?.type === "text" && + selection.anchor.blockId === this._focusBlockId && + selection.focus.blockId === this._focusBlockId + ) { + this._backendLifecycle.updateSelection(null); + return true; + } + + const nativeSelection = root.ownerDocument?.getSelection(); + if (!nativeSelection) return true; + + const range = root.ownerDocument.createRange(); + range.selectNodeContents(inlineEl); + range.collapse(false); + + nativeSelection.removeAllRanges(); + nativeSelection.addRange(range); + return true; + } + + blur(): void { + this._focusController.blur(); + } + + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions: PenFieldEditorFocusOptions = {}, + ): boolean { + if ( + reason === "backend-activate" && + this._suppressNextBackendActivationFocus + ) { + return true; + } + return this._focusController.requestDomFocus( + target, + reason, + options, + policyOptions, + ); + } + + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + return this._focusController.requestActivation(target, reason, options); + } + + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean { + return this._focusController.requestRootFocus(target, reason, options); + } + + setRootElement(element: HTMLElement | null): void { + this._rootElement = element; + if (element) { + this._focusController.notifyRootAttached(element); + } + if (element && this._isEditing) { + this._syncActiveElement(false); + } + } + + setFocused(focused: boolean): void { + if (this._isFocused === focused) return; + this._isFocused = focused; + this._emitStateChange(); + } + + protected _findEditorRoot(): HTMLElement | null { + if (!this._rootElement?.isConnected) return null; + return this._rootElement; + } + + protected _findExpandedHost(): HTMLElement | null { + const root = this._findEditorRoot(); + if (!root) return null; + return root.querySelector( + "[data-pen-editor-blocks-host]", + ) as HTMLElement | null; + } + + attachElement( + element: HTMLElement, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + if (!this._focusBlockId) return false; + if (this._attachedElement === element && this._backendLifecycle.current) + return true; + if (!this.requestActivation(element, "backend-attach", options)) + return false; + this._emitFocusLifecycle({ + type: "backend-attach-started", + editor: this._editor, + target: element, + blockId: this._focusBlockId, + }); + this._backendLifecycle.replace(this._resolveBackendClass()); + + const ytext = this._getYText(this._focusBlockId); + if (!ytext) return false; + + this._suppressNextBackendActivationFocus = + options.domFocus === false || options.passive === true; + try { + this._backendLifecycle.activate(element, ytext); + } finally { + this._suppressNextBackendActivationFocus = false; + } + this._attachedElement = element; + this._emitFocusLifecycle({ + type: "backend-attach-completed", + editor: this._editor, + target: element, + blockId: this._focusBlockId, + }); + this._focusController.resolveAttachmentWaiters(); + return true; + } +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts new file mode 100644 index 0000000..64d1a3a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplRuntime.ts @@ -0,0 +1,434 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { DocumentRangeImpl } from "@pen/core"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import { FieldEditorImplSelection } from "./fieldEditorImplSelection"; +import { + areBlockIdsEqual, + resolveInputMode, +} from "./fieldEditorImplHelpers"; + +export abstract class FieldEditorImplRuntime extends FieldEditorImplSelection { + togglePendingMark(markType: string): boolean { + return this._pendingMarkController.toggle( + markType, + this._isEditing, + this._inputMode, + ); + } + + resolveInsertMarks( + ytext: FieldEditorTextLike, + offset: number, + ): Record | undefined { + return this._pendingMarkController.resolveInsertMarks(ytext, offset); + } + + // ── Cross-block expansion ──────────────────────────────── + + expandTo(blockId: string): void { + if (!this._isEditing || !this._focusBlockId) return; + + const selection = this._editor.selection; + const anchor = + selection?.type === "text" && + selection.blockRange.includes(this._focusBlockId) + ? selection.anchor + : { blockId: this._focusBlockId, offset: 0 }; + const doc = this._editor.documentState; + const activeIdx = doc.indexOf(this._focusBlockId); + const targetIdx = doc.indexOf(blockId); + if (activeIdx < 0 || targetIdx < 0) return; + + const targetOffset = + targetIdx >= activeIdx + ? (this._editor.getBlock(blockId)?.length() ?? 0) + : 0; + + this._editor.selectTextRange(anchor, { + blockId, + offset: targetOffset, + }); + } + + contractToFocused(): void { + if (!this._isEditing || !this._focusBlockId) return; + + const selection = this._editor.selection; + if (selection?.type !== "text") return; + + this._editor.selectTextRange(selection.focus, selection.focus); + } + + // ── Events ─────────────────────────────────────────────── + + onActivate(cb: (blockIds: string[]) => void): Unsubscribe { + this._activateListeners.add(cb); + return () => this._activateListeners.delete(cb); + } + + onDeactivate(cb: (blockIds: string[]) => void): Unsubscribe { + this._deactivateListeners.add(cb); + return () => this._deactivateListeners.delete(cb); + } + + onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { + return this._focusController.onFocusLifecycle(listener); + } + + onSelectionChange(cb: (sel: SelectionState) => void): Unsubscribe { + return this._editor.onSelectionChange(cb); + } + + getSnapshot(): FieldEditorStoreSnapshot { + return { + focusBlockId: this._focusBlockId, + activeBlockIds: this._activeBlockIds, + isEditing: this._isEditing, + isFocused: this._isFocused, + isComposing: this._isComposing, + domSyncVersion: this._domSyncVersion, + inputMode: this._inputMode, + mode: this._mode, + activeCellCoord: this._cellEditingController.activeCellCoord, + }; + } + + notifyDomReconciled(_blockId?: string): void { + this._domSyncVersion += 1; + this._emitStateChange(); + } + + subscribe(callback: () => void): Unsubscribe { + this._storeListeners.add(callback); + return () => this._storeListeners.delete(callback); + } + + waitForAttachment(blockId = this._focusBlockId): Promise { + return this._focusController.waitForAttachment(blockId); + } + + destroy(): void { + this._unsubscribeSelection?.(); + this._unsubscribeSelection = null; + this._unsubscribeHistoryApplied?.(); + this._unsubscribeHistoryApplied = null; + this._sessionReconciler.destroy(); + this._deactivate({ restoreFocus: false }); + this._activateListeners.clear(); + this._deactivateListeners.clear(); + this._storeListeners.clear(); + this._focusController.destroy(); + } + + // ── Internal ───────────────────────────────────────────── + + protected _resolveBackendClass(): InputBackendConstructor { + if (this._mode === "expanded") { + return ExpandedContentEditableBackend; + } + if (this._cellEditingController.activeCellCoord) { + return ContentEditableBackend; + } + if ( + "EditContext" in globalThis && + typeof (globalThis as typeof globalThis & { EditContext?: unknown }) + .EditContext === "function" + ) { + return EditContextBackend; + } + return ContentEditableBackend; + } + + protected _syncActiveElement(focus: boolean): void { + if (!this._focusBlockId) return; + const inlineEl = this._resolveInlineElement(this._focusBlockId); + if (!inlineEl) return; + + this.attachElement(inlineEl); + if (focus) { + this.focus(); + } + } + + protected _restoreFocusAfterDeactivate(blockId: string | null): void { + this._focusController.restoreFocusAfterDeactivate(blockId); + } + + protected _emitStateChange(): void { + for (const callback of this._storeListeners) { + callback(); + } + } + + protected _emitFocusLifecycle(event: PenFocusLifecycleEvent): void { + this._focusController.emitLifecycle(event); + } + + protected _recomputeSurfaceFromSelection(options?: { + syncSelectionToBackend?: boolean; + }): void { + const surface = classifySelectionSurface( + this._editor, + this._editor.selection, + this._focusBlockId, + this._isEditing, + ); + this._updateSurfaceState(surface.mode, surface.blockIds); + if (options?.syncSelectionToBackend ?? true) { + this._backendLifecycle.updateSelection(null); + } + } + + protected _updateSurfaceState( + mode: "inactive" | "single" | "expanded" | "block", + blockIds: string[], + ): void { + const modeChanged = this._mode !== mode; + const blockIdsChanged = !areBlockIdsEqual( + this._activeBlockIds, + blockIds, + ); + if (!modeChanged && !blockIdsChanged) return; + this._mode = mode; + this._activeBlockIds = blockIds; + this._syncBackendForSurfaceMode(); + + if (this._isEditing && blockIdsChanged) { + for (const cb of this._activateListeners) cb([...blockIds]); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [...blockIds], + isEditing: true, + }); + } + + this._emitStateChange(); + } + + protected _syncBackendForSurfaceMode(): void { + if (!this._isEditing || !this._focusBlockId) return; + const NextBackendClass = this._resolveBackendClass(); + if (this._backendLifecycle.hasBackend(NextBackendClass)) { + return; + } + + this._backendLifecycle.replace(NextBackendClass); + + if (this._mode === "expanded") { + const expandedHost = this._findExpandedHost(); + this._attachedElement = null; + if (expandedHost) { + this.attachElement(expandedHost); + } + return; + } + + if (this._mode === "single") { + const inlineEl = this._resolveInlineElement(this._focusBlockId); + if (inlineEl) { + this._attachedElement = null; + this.attachElement(inlineEl); + return; + } + } + + if (!this._attachedElement) return; + + const ytext = this._getYText(this._focusBlockId); + if (!ytext) return; + if (!this.requestActivation(this._attachedElement, "backend-attach")) { + return; + } + + this._backendLifecycle.activate(this._attachedElement, ytext); + } + + protected _startSession( + blockId: string, + options: { + stopCapturing: boolean; + syncSelectionToBackend: boolean; + attachImmediately: boolean; + }, + ): boolean { + if (this._isEditing) this._deactivate({ restoreFocus: false }); + + const block = this._editor.getBlock(blockId); + if (!block) return false; + + const schema = this._editor.schema.resolve(block.type); + if (schema?.fieldEditor === "none") return false; + + this._focusBlockId = blockId; + this._activeBlockIds = [blockId]; + this._isEditing = true; + this._isComposing = false; + this._mode = "single"; + this._pendingMarkController.reset(); + + if (options.stopCapturing) { + this._editor.undoManager.stopCapturing(); + } + + this._inputMode = resolveInputMode(schema); + this._backendLifecycle.replace(this._resolveBackendClass()); + this._attachedElement = null; + if (options.attachImmediately) { + this._syncActiveElement(false); + } + this._recomputeSurfaceFromSelection({ + syncSelectionToBackend: options.syncSelectionToBackend, + }); + + for (const cb of this._activateListeners) cb([...this._activeBlockIds]); + this._emitFocusLifecycle({ + type: "activation-changed", + editor: this._editor, + activeBlockIds: [...this._activeBlockIds], + isEditing: true, + }); + this._emitStateChange(); + return true; + } + + protected _handleHistoryApplied(event: HistoryAppliedEvent): void { + const selection = event.selection; + const nextFocusBlockId = + event.focusBlockId ?? + (selection?.type === "text" ? selection.focus.blockId : null); + if (selection?.type !== "text") { + if (this._isEditing) { + this._deactivate({ restoreFocus: false }); + } + return; + } + + if (!this._isEditing) { + return; + } + + if (nextFocusBlockId) { + this._focusBlockId = nextFocusBlockId; + } + + this._historySelectionCoordinator.beginDeferredProjection( + event.requestId, + ); + + this._recomputeSurfaceFromSelection({ + syncSelectionToBackend: false, + }); + } + + protected _attachedElementOwnsFocus(): boolean { + return this._focusController.attachedElementOwnsFocus(); + } + + protected _resolveInlineElement(blockId: string): HTMLElement | null { + const root = this._findEditorRoot(); + if (!root) return null; + const cellElement = + this._cellEditingController.resolveInlineElement(blockId); + if (cellElement) return cellElement; + return queryInlineElement(root, blockId); + } + + protected _getYText(blockId: string): FieldEditorTextLike | null { + return getResolvedYText( + this._editor, + blockId, + this._cellEditingController.activeCellCoord, + ); + } + + protected _getYTextForCell( + blockId: string, + row: number, + col: number, + ): FieldEditorTextLike | null { + return getCellYText(this._editor, blockId, row, col); + } + + protected _selectElementContents(element: HTMLElement): void { + if ( + !this.requestDomFocus(element, "select-all", { + preventScroll: true, + }) + ) { + return; + } + const selection = element.ownerDocument?.getSelection(); + if (!selection) return; + + const range = element.ownerDocument.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + } + + protected _resolveActiveCellElement( + rootElement?: HTMLElement | null, + ): HTMLElement | null { + return this._cellEditingController.resolveActiveCellElement( + rootElement, + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts b/packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts new file mode 100644 index 0000000..d9c846d --- /dev/null +++ b/packages/rendering/dom/src/field-editor/fieldEditorImplSelection.ts @@ -0,0 +1,469 @@ +import type { + FieldEditor, + Editor, + BlockSchema, + HistoryAppliedEvent, + SelectionState, + Unsubscribe, +} from "@pen/types"; +import { DocumentRangeImpl } from "@pen/core"; +import { + hasFieldEditorSurface, + resolveFieldEditorInputMode, + usesInlineTextSelection, +} from "@pen/types"; +import { EditContextBackend } from "./editContextBackend"; +import { ContentEditableBackend } from "./contenteditableBackend"; +import { + BackendLifecycleController, + type InputBackendConstructor, +} from "./backendLifecycleController"; +import { CellEditingController } from "./cellEditingController"; +import { ExpandedContentEditableBackend } from "./expandedContentEditableBackend"; +import { FocusController } from "./focusController"; +import { HistorySelectionCoordinator } from "./historySelectionCoordinator"; +import { PendingMarkController } from "./pendingMarkController"; +import { SelectAllController } from "./selectAllController"; +import { FieldEditorSelectionCoordinator } from "./selectionCoordinator"; +import type { + FieldEditorSelectionSnapshot, + FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SessionReconciler } from "./sessionReconciler"; +import { classifySelectionSurface } from "./crossBlock"; +import type { + ActiveCellCoord, + FieldEditorFocusReason, + FieldEditorInputController, + FieldEditorSession, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, +} from "./controller"; +import { getCellYText, getResolvedYText } from "./contentResolution"; +import type { FieldEditorTextLike } from "./crdt"; +import { + domSelectionToEditor, + queryBlockElement, + queryInlineElement, +} from "./selectionBridge"; +import { + getEditorBlockSelectionLength, + getEditorBlockSelectionRole, +} from "../utils/blockSelectionSemantics"; +import { + getEditorFlowCapability, + shouldForceBlockScopedSelectAll, +} from "../utils/flowCapabilities"; +import type { FieldEditorStoreSnapshot } from "./store"; +import type { EditorSelectAllBehavior } from "../constants/selectAll"; +import { FieldEditorImplLifecycle } from "./fieldEditorImplLifecycle"; +import { + getFullDocumentTextRange, + pointsEqual, +} from "./fieldEditorImplHelpers"; + +export abstract class FieldEditorImplSelection extends FieldEditorImplLifecycle { + syncTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + if (!this._isEditing) return; + if (this._focusBlockId !== blockId) return; + + if ( + this._selectionCoordinator.prepareSyncedTextSelection( + this._editor.selection, + blockId, + anchorOffset, + focusOffset, + ) === "skip" + ) { + return; + } + this.setTextSelection(blockId, anchorOffset, focusOffset); + } + + applyDocumentTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): void { + this._selectionCoordinator.recordUserSelectionIntent(); + this._selectionCoordinator.suppressNextDomSelectionProjection(); + + if (!this._isEditing || !this._focusBlockId) { + this._startSession(anchor.blockId, { + stopCapturing: false, + syncSelectionToBackend: false, + attachImmediately: false, + }); + } else { + const blockRange = new DocumentRangeImpl( + anchor, + focus, + this._editor.internals.doc, + ).blockRange; + if (!blockRange.includes(this._focusBlockId)) { + this._focusBlockId = anchor.blockId; + } + } + + this._editor.selectTextRange(anchor, focus); + this._emitStateChange(); + } + + applyDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + options?: { + focusBlockId?: string; + }, + ): void { + if (anchor.blockId !== focus.blockId) { + this.applyDocumentTextSelection(anchor, focus); + return; + } + + const isProgrammaticDomSelection = + this._selectionCoordinator.isProgrammaticDomTextSelection( + anchor, + focus, + ); + if (!isProgrammaticDomSelection) { + this._selectionCoordinator.recordUserSelectionIntent(); + } + this._selectionCoordinator.suppressNextDomSelectionProjection(); + + if ( + anchor.blockId === focus.blockId && + (!this._isEditing || this._focusBlockId !== anchor.blockId) + ) { + this._startSession(anchor.blockId, { + stopCapturing: false, + syncSelectionToBackend: false, + attachImmediately: false, + }); + } + + if (anchor.blockId === focus.blockId) { + this.setTextSelection(anchor.blockId, anchor.offset, focus.offset); + return; + } + + if (options?.focusBlockId) { + this._focusBlockId = options.focusBlockId; + } + this._editor.selectTextRange(anchor, focus); + this._emitStateChange(); + } + + shouldHandleDomSelectionChange(isApplyingSelection: number): boolean { + return this._selectionCoordinator.shouldHandleDomSelectionChange( + this._focusBlockId, + isApplyingSelection, + ); + } + + resetBackendSelectionAuthority(): void { + this._selectionCoordinator.resetAuthority(); + } + + setBackendSelectionAuthority( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._selectionCoordinator.setAuthoritySelection(source, selection); + } + + getBackendSelectionAuthority( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._selectionCoordinator.getAuthoritySelection( + source, + blockId, + ); + } + + hasBackendSelectionAuthority(source: FieldEditorSelectionSource): boolean { + return this._selectionCoordinator.hasAuthoritySelection(source); + } + + clearBackendSelectionAuthority(source: FieldEditorSelectionSource): void { + this._selectionCoordinator.clearAuthoritySelection(source); + } + + applyBackendSelectionUntilNextFrame(): void { + this._selectionCoordinator.applySelectionUntilNextFrame(); + } + + getBackendSelectionApplicationDepth(): number { + return this._selectionCoordinator.isApplyingSelection; + } + + setEditContextSelectionSnapshot( + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._selectionCoordinator.setEditContextSelection(selection); + } + + getEditContextSelectionSnapshot( + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._selectionCoordinator.getEditContextSelection(blockId); + } + + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + return this._selectionCoordinator.resolveProgrammaticInputRange( + blockId, + liveRange, + ); + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + return this._selectionCoordinator.shouldIgnoreDomTextSelection( + anchor, + focus, + ); + } + + setTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + if (anchorOffset !== focusOffset) { + this._pendingMarkController.clear(true); + } + this._editor.selectText(blockId, anchorOffset, focusOffset); + this._selectionCoordinator.notifyTextSelectionSet( + blockId, + anchorOffset, + focusOffset, + ); + this._emitStateChange(); + } + + activateTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._selectionCoordinator.activateTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + async focusTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options: PenFieldEditorFocusOptions = {}, + ): Promise { + this.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + const attached = await this.waitForAttachment(blockId); + if (!attached) { + return false; + } + if (options.domFocus === false || options.passive) { + return true; + } + const focused = this.focus(options); + this.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + ); + return focused; + } + + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._selectionCoordinator.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + collapseSelectionToFocus(): void { + const selection = this._editor.selection; + if (selection?.type !== "text") return; + + this._collapseAndProject(selection.focus); + } + + collapseSelectionToAnchor(): void { + const selection = this._editor.selection; + if (selection?.type !== "text") return; + + this._collapseAndProject(selection.anchor); + } + + collapseSelectionToPoint(point: { blockId: string; offset: number }): void { + this._collapseAndProject(point); + } + + protected _collapseAndProject(point: { + blockId: string; + offset: number; + }): void { + this.setTextSelection(point.blockId, point.offset, point.offset); + + if (!this._isEditing || this._focusBlockId !== point.blockId) { + this.activate(point.blockId); + } + + this._selectionCoordinator.syncDomSelectionOnce(); + } + + delegate(blockSchema: BlockSchema): boolean { + return hasFieldEditorSurface(blockSchema); + } + + getPendingMarks(): Readonly> { + return this._pendingMarkController.getSnapshot(); + } + + clearPendingMarks(): void { + this._pendingMarkController.clear(); + } + + resetSelectAllCycle(): void { + this._selectAllController.resetCycle(); + } + + protected _syncSelectionToDOM(): void { + if (!this._isEditing) return; + this._selectionCoordinator.syncDomSelectionOnce(); + } + + protected _resolveSelectAllBlockId( + rootElement?: HTMLElement | null, + ): string | null { + const selection = this._editor.selection; + if (selection?.type === "text" && !selection.isMultiBlock) { + return selection.focus.blockId; + } + if ( + this._selectAllController.getBehavior() === "block-first" && + selection?.type === "block" && + selection.blockIds.length === 1 + ) { + return selection.blockIds[0] ?? null; + } + if (selection?.type === "cell") { + return selection.blockId; + } + + if (this._focusBlockId) { + return this._focusBlockId; + } + + const root = rootElement ?? this._findEditorRoot(); + if (!root) { + return null; + } + + const domSelection = domSelectionToEditor(root); + if ( + domSelection && + domSelection.anchor.blockId === domSelection.focus.blockId + ) { + return domSelection.focus.blockId; + } + + const activeElement = root.ownerDocument?.activeElement; + if (activeElement instanceof HTMLElement) { + return ( + activeElement + .closest("[data-block-id]") + ?.getAttribute("data-block-id") ?? null + ); + } + + return null; + } + + protected _selectionMatchesSelectAllCycle( + cycle: { blockId: string; scope: "cell" | "block" | "document" }, + selection: SelectionState | null, + ): boolean { + if (cycle.scope === "cell") { + return ( + selection?.type === "cell" && + selection.blockId === cycle.blockId + ); + } + + if (cycle.scope === "block") { + const blockLength = getEditorBlockSelectionLength( + this._editor, + cycle.blockId, + ); + const blockRole = getEditorBlockSelectionRole( + this._editor, + cycle.blockId, + ); + if (blockRole && blockRole !== "editable-inline") { + return ( + selection?.type === "block" && + selection.blockIds.length === 1 && + selection.blockIds[0] === cycle.blockId + ); + } + + if (selection?.type !== "text") { + return false; + } + return ( + !selection.isMultiBlock && + selection.anchor.blockId === cycle.blockId && + selection.focus.blockId === cycle.blockId && + Math.min(selection.anchor.offset, selection.focus.offset) === + 0 && + Math.max(selection.anchor.offset, selection.focus.offset) === + blockLength + ); + } + + const range = getFullDocumentTextRange(this._editor); + if (!range) { + return false; + } + + if (selection?.type !== "text") { + return false; + } + + return ( + selection.isMultiBlock && + ((pointsEqual(selection.anchor, range.start) && + pointsEqual(selection.focus, range.end)) || + (pointsEqual(selection.anchor, range.end) && + pointsEqual(selection.focus, range.start))) + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/focusController.ts b/packages/rendering/dom/src/field-editor/focusController.ts new file mode 100644 index 0000000..11aa42a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/focusController.ts @@ -0,0 +1,289 @@ +import type { Editor, Unsubscribe } from "@pen/types"; +import type { + FieldEditorFocusReason, + FieldEditorFocusRequest, + PenFieldEditorFocusOptions, + PenFocusAction, + PenFocusDecision, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusReason, +} from "./controller"; +import { queryBlockElement } from "./selectionBridge"; + +type FocusControllerOptions = { + editor: Editor; + getRootElement: () => HTMLElement | null; + getFocusBlockId: () => string | null; + getAttachedElement: () => HTMLElement | null; +}; + +type AttachmentWaiter = { + check: () => void; + resolve: (attached: boolean) => void; + done: boolean; +}; + +const ALLOW_FOCUS_DECISION: PenFocusDecision = { type: "allow" }; + +export class FocusController { + private readonly _editor: Editor; + private readonly _getRootElement: () => HTMLElement | null; + private readonly _getFocusBlockId: () => string | null; + private readonly _getAttachedElement: () => HTMLElement | null; + private _focusPolicy: PenFocusPolicy | undefined; + private readonly _focusLifecycleListeners = + new Set(); + private readonly _attachmentWaiters = new Set(); + + constructor(options: FocusControllerOptions) { + this._editor = options.editor; + this._getRootElement = options.getRootElement; + this._getFocusBlockId = options.getFocusBlockId; + this._getAttachedElement = options.getAttachedElement; + } + + setFocusPolicy(focusPolicy: PenFocusPolicy | undefined): void { + this._focusPolicy = focusPolicy; + } + + requestDomFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + policyOptions: PenFieldEditorFocusOptions = {}, + ): boolean { + const request = this._createFocusRequest(target, reason, policyOptions); + const decision = this._decideFocus(request); + if (decision.type === "deny") { + this._emitFocusDenied(request); + return false; + } + if (decision.type === "allow" && !request.passive) { + target.focus(options); + } + return true; + } + + requestActivation( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): boolean { + const request = this._createFocusRequest(target, reason, options); + const decision = this._decideFocus(request); + if (decision.type === "deny") { + this._emitFocusDenied(request); + return false; + } + return true; + } + + requestRootFocus( + target: HTMLElement, + reason: FieldEditorFocusReason, + options?: FocusOptions, + ): boolean { + return this.requestDomFocus(target, reason, options); + } + + blur(): void { + const root = this._getRootElement(); + if (!root) return; + const activeEl = root.ownerDocument?.activeElement; + if (activeEl instanceof HTMLElement && root.contains(activeEl)) { + activeEl.blur(); + } + } + + restoreFocusAfterDeactivate(blockId: string | null): void { + const root = this._getRootElement(); + if (!root) return; + + if (blockId) { + const blockEl = queryBlockElement(root, blockId); + if (blockEl) { + this.requestDomFocus(blockEl, "restore", { + preventScroll: true, + }); + return; + } + } + + this.requestDomFocus(root, "restore", { preventScroll: true }); + } + + attachedElementOwnsFocus(): boolean { + const attachedElement = this._getAttachedElement(); + if (!attachedElement) { + return false; + } + const activeElement = attachedElement.ownerDocument?.activeElement; + return activeElement instanceof Node + ? attachedElement.contains(activeElement) + : false; + } + + notifyRootAttached(root: HTMLElement): void { + this.emitLifecycle({ + type: "field-editor-attached", + editor: this._editor, + root, + }); + } + + resolveAttachmentWaiters(): void { + for (const waiter of this._attachmentWaiters) { + waiter.check(); + } + } + + waitForAttachment( + blockId: string | null = this._getFocusBlockId(), + ): Promise { + const isAttached = () => { + const attachedElement = this._getAttachedElement(); + return ( + attachedElement?.isConnected === true && + (blockId == null || this._getFocusBlockId() === blockId) + ); + }; + + if (isAttached()) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + let frame = 0; + const complete = (waiter: AttachmentWaiter, attached: boolean) => { + if (waiter.done) { + return; + } + waiter.done = true; + this._attachmentWaiters.delete(waiter); + waiter.resolve(attached); + }; + const waiter: AttachmentWaiter = { + check: () => { + if (waiter.done) { + return; + } + if (isAttached()) { + complete(waiter, true); + return; + } + if (frame >= 4) { + complete(waiter, false); + return; + } + frame += 1; + requestAnimationFrame(waiter.check); + }, + resolve, + done: false, + }; + const check = () => { + waiter.check(); + }; + this._attachmentWaiters.add(waiter); + requestAnimationFrame(check); + }); + } + + onFocusLifecycle(listener: PenFocusLifecycleListener): Unsubscribe { + this._focusLifecycleListeners.add(listener); + return () => this._focusLifecycleListeners.delete(listener); + } + + emitLifecycle(event: PenFocusLifecycleEvent): void { + for (const listener of this._focusLifecycleListeners) { + listener(event); + } + } + + destroy(): void { + for (const waiter of this._attachmentWaiters) { + if (!waiter.done) { + waiter.done = true; + waiter.resolve(false); + } + } + this._attachmentWaiters.clear(); + this._focusLifecycleListeners.clear(); + } + + private _createFocusRequest( + target: HTMLElement, + reason: FieldEditorFocusReason, + options: PenFieldEditorFocusOptions = {}, + ): FieldEditorFocusRequest { + return { + editor: this._editor, + target, + root: this._getRootElement(), + reason, + action: resolvePenFocusAction(reason), + source: options.reason ?? resolvePenFocusReason(reason), + blockId: this._getFocusBlockId(), + passive: options.passive ?? options.domFocus === false, + }; + } + + private _decideFocus(request: FieldEditorFocusRequest): PenFocusDecision { + const policyDecision = this._focusPolicy?.decide(request); + if (policyDecision) { + return request.passive && policyDecision.type === "allow" + ? { type: "allow-passive" } + : policyDecision; + } + + return request.passive + ? { type: "allow-passive" } + : ALLOW_FOCUS_DECISION; + } + + private _emitFocusDenied(request: FieldEditorFocusRequest): void { + this._focusPolicy?.onDenied?.(request); + this.emitLifecycle({ + type: "focus-request-denied", + request, + }); + } +} + +function resolvePenFocusAction(reason: FieldEditorFocusReason): PenFocusAction { + switch (reason) { + case "backend-attach": + case "backend-activate": + return "attach-backend"; + case "selection-project": + case "selection-activate": + case "selection-sync": + return "project-selection"; + case "restore": + return "restore"; + case "select-all": + return "select-all"; + case "activate": + case "cell": + return "activate"; + } +} + +function resolvePenFocusReason(reason: FieldEditorFocusReason): PenFocusReason { + switch (reason) { + case "backend-attach": + case "backend-activate": + return "backend"; + case "selection-project": + case "selection-activate": + case "selection-sync": + return "selection-sync"; + case "select-all": + case "cell": + return "keyboard"; + case "activate": + case "restore": + return "programmatic"; + } +} diff --git a/packages/rendering/dom/src/field-editor/index.ts b/packages/rendering/dom/src/field-editor/index.ts index 32ce4a3..eb51f26 100644 --- a/packages/rendering/dom/src/field-editor/index.ts +++ b/packages/rendering/dom/src/field-editor/index.ts @@ -1,7 +1,4 @@ -export type { - FieldEditorStore, - FieldEditorStoreSnapshot, -} from "./store"; +export type { FieldEditorStore, FieldEditorStoreSnapshot } from "./store"; export { applyDeltaToDOM, fullReconcileToDOM, @@ -19,6 +16,32 @@ export { type SelectionPoint, type TextDiffOp, } from "./selectionBridge"; +export { + INLINE_ATOM_LOGICAL_LENGTH, + buildMoveInlineAtomOps, + getInlineAtomAtOffset, + moveInlineAtom, + replaceInlineAtomWithText, + resolveInlineAtomDropTarget, + type InlineAtomDropTarget, + type InlineAtomSnapshot, + type InlineAtomSource, + type MoveInlineAtomOptions, + type ReplaceInlineAtomWithTextOptions, + type ResolveInlineAtomDropTargetOptions, +} from "./inlineAtomInteraction"; +export type { + FieldEditorFocusReason, + FieldEditorFocusRequest, + PenFocusAction, + PenFocusDecision, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusRequest, + PenFocusReason, +} from "./controller"; export { expandFieldEditorRange, contractFieldEditorRange, diff --git a/packages/rendering/dom/src/field-editor/inlineAtomDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts new file mode 100644 index 0000000..1a0765f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomDom.ts @@ -0,0 +1,230 @@ +import type { SchemaRegistry } from "@pen/types"; +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { + INLINE_ATOM_CARET_BOUNDARY_TEXT, + INLINE_ATOM_REPLACEMENT_TEXT, + resolveInlineAtomDisplayText, + resolveInlineAtomInsert, + type InlineAtomInsert, +} from "./inlineAtomModel"; +export { + INLINE_ATOM_CARET_BOUNDARY_TEXT, + INLINE_ATOM_REPLACEMENT_TEXT, + resolveInlineAtomInsert, +} from "./inlineAtomModel"; + +export type InlineAtomCaretBoundarySide = "before" | "after"; + +export interface InlineAtomElementData extends InlineAtomInsert { + text: string; +} + +const inlineAtomElementData = new WeakMap(); + +export function createInlineAtomCaretBoundaryElement( + side: InlineAtomCaretBoundarySide, +): HTMLElement { + const element = document.createElement("span"); + element.setAttribute(DATA_ATTRS.inlineAtomCaretBoundary, ""); + element.setAttribute(DATA_ATTRS.inlineAtomCaretSide, side); + element.appendChild( + document.createTextNode(INLINE_ATOM_CARET_BOUNDARY_TEXT), + ); + return element; +} + +function createInlineAtomChipElement( + insert: unknown, + registry: SchemaRegistry, +): HTMLElement { + const atom = resolveInlineAtomInsert(insert); + const element = document.createElement("span"); + element.setAttribute(DATA_ATTRS.inlineAtom, ""); + element.contentEditable = "false"; + + if (!atom) { + element.textContent = INLINE_ATOM_REPLACEMENT_TEXT; + return element; + } + + element.setAttribute(DATA_ATTRS.inlineAtomType, atom.type); + element.setAttribute(DATA_ATTRS.inlineAtomProps, serializeInlineAtomProps(atom.props)); + const text = resolveInlineAtomDisplayText(atom, registry); + element.setAttribute("aria-label", text); + element.textContent = text; + inlineAtomElementData.set(element, { + ...atom, + text, + }); + return element; +} + +export function createInlineAtomElement( + insert: unknown, + registry: SchemaRegistry, +): HTMLElement { + const host = document.createElement("span"); + host.setAttribute(DATA_ATTRS.inlineAtomHost, ""); + host.appendChild(createInlineAtomCaretBoundaryElement("before")); + host.appendChild(createInlineAtomChipElement(insert, registry)); + host.appendChild(createInlineAtomCaretBoundaryElement("after")); + return host; +} + +export function getInlineAtomElementData( + element: Element, +): InlineAtomElementData | null { + const chip = getInlineAtomChipElement(element); + if (!chip) { + return null; + } + return inlineAtomElementData.get(chip) ?? deserializeInlineAtomElementData(chip); +} + +export function copyInlineAtomElementData( + source: Element, + target: Element, +): void { + const sourceChip = getInlineAtomChipElement(source); + const targetChip = getInlineAtomChipElement(target); + if (!sourceChip || !targetChip) { + return; + } + + const data = getInlineAtomElementData(sourceChip); + if (!data) { + return; + } + + inlineAtomElementData.set(targetChip, { + type: data.type, + props: { ...data.props }, + text: data.text, + }); + targetChip.setAttribute(DATA_ATTRS.inlineAtomType, data.type); + targetChip.setAttribute(DATA_ATTRS.inlineAtomProps, serializeInlineAtomProps(data.props)); + targetChip.setAttribute("aria-label", data.text); +} + +function serializeInlineAtomProps(props: Record): string { + try { + return JSON.stringify(props); + } catch { + return "{}"; + } +} + +function deserializeInlineAtomElementData( + element: HTMLElement, +): InlineAtomElementData | null { + const type = element.getAttribute(DATA_ATTRS.inlineAtomType); + if (!type) { + return null; + } + return { + type, + props: parseInlineAtomProps(element.getAttribute(DATA_ATTRS.inlineAtomProps)), + text: element.getAttribute("aria-label") ?? element.textContent ?? "", + }; +} + +function parseInlineAtomProps(value: string | null): Record { + if (!value) { + return {}; + } + try { + const props = JSON.parse(value); + return props && typeof props === "object" && !Array.isArray(props) + ? (props as Record) + : {}; + } catch { + return {}; + } +} + +export function areInlineAtomElementDataEqual( + left: Element, + right: Element, +): boolean { + const leftData = getInlineAtomElementData(left); + const rightData = getInlineAtomElementData(right); + if (!leftData || !rightData) { + return leftData === rightData; + } + + return ( + leftData.type === rightData.type && + leftData.text === rightData.text && + shallowEqualRecords(leftData.props, rightData.props) + ); +} + +export function isInlineAtomCaretBoundaryNode( + node: Node | null, +): node is HTMLElement { + return ( + node instanceof HTMLElement && + node.hasAttribute(DATA_ATTRS.inlineAtomCaretBoundary) + ); +} + +export function isInlineAtomHostNode(node: Node | null): node is HTMLElement { + return ( + node instanceof HTMLElement && + node.hasAttribute(DATA_ATTRS.inlineAtomHost) + ); +} + +export function isInlineAtomChipNode(node: Node | null): node is HTMLElement { + return ( + node instanceof HTMLElement && + node.hasAttribute(DATA_ATTRS.inlineAtom) && + !isInlineAtomHostNode(node) + ); +} + +export function isInlineAtomNode(node: Node | null): node is HTMLElement { + return isInlineAtomHostNode(node) || isInlineAtomChipNode(node); +} + +function shallowEqualRecords( + left: Record, + right: Record, +): boolean { + if (left === right) { + return true; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => Object.is(left[key], right[key])); +} + +function getInlineAtomChipElement(element: Element): HTMLElement | null { + if (element instanceof HTMLElement && isInlineAtomChipNode(element)) { + return element; + } + + if (element instanceof HTMLElement && isInlineAtomHostNode(element)) { + for (const child of Array.from(element.childNodes)) { + if (isInlineAtomChipNode(child)) { + return child; + } + } + } + + return null; +} + + +export { + domPointToLogicalOffset, + findLogicalDOMPoint, + getInlineAtomPointerOffset, + getLogicalNodeLength, + getLogicalTextContent, +} from "./inlineAtomLogicalDom"; diff --git a/packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts b/packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts new file mode 100644 index 0000000..5ee2dc3 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomInteraction.ts @@ -0,0 +1,339 @@ +import type { + ApplyOptions, + DocumentOp, + Editor, + FieldEditor, + InlineDelta, + InlineNodeDeltaInsert, +} from "@pen/types"; +import { FIELD_EDITOR_SLOT_KEY } from "@pen/types"; +import { + pointToEditorSelectionPoint, + type SelectionPoint, +} from "./selectionBridge"; + +export const INLINE_ATOM_LOGICAL_LENGTH = 1; + +const ZERO_WIDTH_SPACE = "\u200B"; +const OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"; +const DEFAULT_APPLY_OPTIONS: ApplyOptions = { origin: "user", undoGroup: true }; + +export interface InlineAtomSource { + editor: Editor; + blockId: string; + offset: number; +} + +export interface InlineAtomDropTarget { + editor: Editor; + blockId: string; + offset: number; +} + +export interface InlineAtomSnapshot { + blockId: string; + offset: number; + type: string; + props: Record; + text: string; +} + +export interface ResolveInlineAtomDropTargetOptions { + editor: Editor; + root: HTMLElement | null; + clientX: number; + clientY: number; +} + +export interface MoveInlineAtomOptions { + source: InlineAtomSource; + target: InlineAtomDropTarget; + apply?: ApplyOptions; +} + +export interface ReplaceInlineAtomWithTextOptions { + source: InlineAtomSource; + text: string; + selection?: "all" | "end" | "none"; + apply?: ApplyOptions; +} + +export function getInlineAtomAtOffset( + editor: Editor, + source: Pick, +): InlineAtomSnapshot | null { + const block = editor.getBlock(source.blockId); + if (!block) { + return null; + } + + let offset = 0; + for (const delta of block.inlineDeltas()) { + const length = getInlineDeltaLength(delta); + if (offset === source.offset && typeof delta.insert !== "string") { + return { + blockId: source.blockId, + offset: source.offset, + type: delta.insert.type, + props: { ...delta.insert.props }, + text: getInlineAtomText(editor, delta.insert), + }; + } + + offset += length; + } + + return null; +} + +export function resolveInlineAtomDropTarget({ + editor, + root, + clientX, + clientY, +}: ResolveInlineAtomDropTargetOptions): InlineAtomDropTarget | null { + if (!root) { + return null; + } + + const point = pointToEditorSelectionPoint(root, clientX, clientY); + if (!point) { + return null; + } + + return { + editor, + blockId: point.blockId, + offset: point.offset, + }; +} + +export function buildMoveInlineAtomOps( + editor: Editor, + source: Pick, + target: SelectionPoint, +): DocumentOp[] { + const sourceAtom = getInlineAtomAtOffset(editor, source); + if ( + !sourceAtom || + !editor.getBlock(target.blockId) || + isNoopInlineAtomMove(source, target) + ) { + return []; + } + + const targetOffset = getAdjustedTargetOffset(source, target); + return [ + { + type: "delete-text", + blockId: source.blockId, + offset: source.offset, + length: INLINE_ATOM_LOGICAL_LENGTH, + }, + { + type: "insert-inline-node", + blockId: target.blockId, + offset: targetOffset, + nodeType: sourceAtom.type, + props: { ...sourceAtom.props }, + }, + ]; +} + +export function moveInlineAtom({ + source, + target, + apply, +}: MoveInlineAtomOptions): boolean { + if (source.editor === target.editor) { + return moveInlineAtomWithinEditor({ source, target, apply }); + } + + return moveInlineAtomBetweenEditors({ source, target, apply }); +} + +export function replaceInlineAtomWithText({ + source, + text, + selection = "end", + apply, +}: ReplaceInlineAtomWithTextOptions): boolean { + const sourceAtom = getInlineAtomAtOffset(source.editor, source); + if (!sourceAtom) { + return false; + } + + const ops: DocumentOp[] = [ + { + type: "delete-text", + blockId: source.blockId, + offset: source.offset, + length: INLINE_ATOM_LOGICAL_LENGTH, + }, + ]; + if (text.length > 0) { + ops.push({ + type: "insert-text", + blockId: source.blockId, + offset: source.offset, + text, + }); + } + + source.editor.apply(ops, apply ?? DEFAULT_APPLY_OPTIONS); + + const endOffset = source.offset + text.length; + + if (selection === "all") { + source.editor.selectText( + source.blockId, + source.offset, + endOffset, + ); + } else if (selection === "end") { + source.editor.selectText(source.blockId, endOffset, endOffset); + } + + const fieldEditor = source.editor.internals.getSlot( + FIELD_EDITOR_SLOT_KEY, + ); + if (fieldEditor && selection !== "none") { + if (selection === "all") { + if (typeof fieldEditor.activateTextSelection === "function") { + fieldEditor.activateTextSelection( + source.blockId, + source.offset, + endOffset, + ); + } else { + fieldEditor.activate(source.blockId); + } + } else if (selection === "end") { + if (typeof fieldEditor.activateTextSelection === "function") { + fieldEditor.activateTextSelection( + source.blockId, + endOffset, + endOffset, + ); + } else { + fieldEditor.activate(source.blockId); + } + } + fieldEditor.focus(); + } + + return true; +} + +function moveInlineAtomWithinEditor({ + source, + target, + apply, +}: MoveInlineAtomOptions): boolean { + const ops = buildMoveInlineAtomOps(source.editor, source, target); + if (ops.length === 0) { + return false; + } + + const targetOffset = getAdjustedTargetOffset(source, target); + source.editor.apply(ops, apply ?? DEFAULT_APPLY_OPTIONS); + source.editor.selectText( + target.blockId, + targetOffset + INLINE_ATOM_LOGICAL_LENGTH, + targetOffset + INLINE_ATOM_LOGICAL_LENGTH, + ); + return true; +} + +function moveInlineAtomBetweenEditors({ + source, + target, + apply, +}: MoveInlineAtomOptions): boolean { + const sourceAtom = getInlineAtomAtOffset(source.editor, source); + if ( + !sourceAtom || + !target.editor.getBlock(target.blockId) || + !canInsertInlineAtom(target.editor, sourceAtom) + ) { + return false; + } + + const applyOptions = apply ?? DEFAULT_APPLY_OPTIONS; + target.editor.apply( + [ + { + type: "insert-inline-node", + blockId: target.blockId, + offset: target.offset, + nodeType: sourceAtom.type, + props: { ...sourceAtom.props }, + }, + ], + applyOptions, + ); + source.editor.apply( + [ + { + type: "delete-text", + blockId: source.blockId, + offset: source.offset, + length: INLINE_ATOM_LOGICAL_LENGTH, + }, + ], + applyOptions, + ); + target.editor.selectText( + target.blockId, + target.offset + INLINE_ATOM_LOGICAL_LENGTH, + target.offset + INLINE_ATOM_LOGICAL_LENGTH, + ); + return true; +} + +function canInsertInlineAtom( + editor: Editor, + atom: Pick, +): boolean { + return editor.schema.resolveInline(atom.type)?.kind === "node"; +} + +function isNoopInlineAtomMove( + source: Pick, + target: Pick, +): boolean { + const sourceEndOffset = source.offset + INLINE_ATOM_LOGICAL_LENGTH; + return ( + target.blockId === source.blockId && + target.offset >= source.offset && + target.offset <= sourceEndOffset + ); +} + +function getAdjustedTargetOffset( + source: Pick, + target: Pick, +): number { + return target.blockId === source.blockId && target.offset > source.offset + ? target.offset - INLINE_ATOM_LOGICAL_LENGTH + : target.offset; +} + +function getInlineDeltaLength(delta: InlineDelta): number { + return typeof delta.insert === "string" + ? delta.insert + .replaceAll(ZERO_WIDTH_SPACE, "") + .replaceAll(OBJECT_REPLACEMENT_CHARACTER, "").length + : INLINE_ATOM_LOGICAL_LENGTH; +} + +function getInlineAtomText( + editor: Editor, + atom: InlineNodeDeltaInsert, +): string { + return ( + editor.schema + .resolveInline(atom.type) + ?.serialize.toMarkdown?.("", atom.props) ?? "" + ); +} diff --git a/packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts b/packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts new file mode 100644 index 0000000..18b553f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomLogicalDom.ts @@ -0,0 +1,441 @@ +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { INLINE_ATOM_REPLACEMENT_TEXT } from "./inlineAtomModel"; +import { + isInlineAtomCaretBoundaryNode, + isInlineAtomChipNode, + isInlineAtomHostNode, + isInlineAtomNode, + type InlineAtomCaretBoundarySide, +} from "./inlineAtomDom"; + +const VIRTUAL_INLINE_DECORATION_ATTRIBUTE = "data-pen-virtual-inline"; + +function getInlineAtomHostElement(node: Node): HTMLElement | null { + if (node instanceof HTMLElement && isInlineAtomHostNode(node)) { + return node; + } + + if (node instanceof HTMLElement && isInlineAtomChipNode(node)) { + const parent = node.parentElement; + return parent && isInlineAtomHostNode(parent) ? parent : null; + } + + if (isInlineAtomCaretBoundaryNode(node)) { + const parent = node.parentElement; + return parent && isInlineAtomHostNode(parent) ? parent : null; + } + + return null; +} + +function getInlineAtomCaretBoundaryElement( + host: HTMLElement, + side: InlineAtomCaretBoundarySide, +): HTMLElement | null { + for (const child of Array.from(host.childNodes)) { + if ( + isInlineAtomCaretBoundaryNode(child) && + child.getAttribute(DATA_ATTRS.inlineAtomCaretSide) === side + ) { + return child; + } + } + return null; +} + +function getInlineAtomCaretBoundaryTextPoint( + host: HTMLElement, + side: InlineAtomCaretBoundarySide, +): { node: Node; offset: number } | null { + const boundary = getInlineAtomCaretBoundaryElement(host, side); + if (!boundary) { + return null; + } + + const textNode = boundary.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + return { + node: textNode, + offset: side === "before" ? 0 : (textNode.textContent?.length ?? 0), + }; +} + +function resolveLogicalInlineAtomUnit(node: HTMLElement): HTMLElement { + const host = getInlineAtomHostElement(node); + if (host) { + return host; + } + return node; +} + +export function getLogicalNodeLength(node: Node): number { + if (isVirtualInlineDecorationNode(node)) { + return 0; + } + if ( + isInlineAtomCaretBoundaryNode(node) || + hasInlineAtomCaretBoundaryAncestor(node) + ) { + return 0; + } + + if (isInlineAtomHostNode(node)) { + return 1; + } + + if (isInlineAtomChipNode(node)) { + return getInlineAtomHostElement(node) ? 0 : 1; + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length ?? 0; + } + + let length = 0; + for (const child of Array.from(node.childNodes)) { + length += getLogicalNodeLength(child); + } + return length; +} + +export function getLogicalTextContent(root: HTMLElement): string { + let text = ""; + for (const child of Array.from(root.childNodes)) { + text += getLogicalNodeText(child); + } + return text; +} + +export function getInlineAtomPointerOffset( + container: HTMLElement, + clientX: number, + clientY: number, +): number | null { + const atomElements = Array.from( + container.querySelectorAll(`[${DATA_ATTRS.inlineAtom}]`), + ).filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ); + if (atomElements.length === 0) { + return null; + } + + let bestOffset: number | null = null; + let bestScore = Number.POSITIVE_INFINITY; + + for (const atomElement of atomElements) { + const rect = atomElement.getBoundingClientRect(); + const dx = + clientX < rect.left + ? rect.left - clientX + : clientX > rect.right + ? clientX - rect.right + : 0; + const dy = + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0; + const score = dy * 1000 + dx; + if (score >= bestScore) { + continue; + } + + const logicalAtom = resolveLogicalInlineAtomUnit(atomElement); + const atomOffset = getOffsetBeforeNode(container, logicalAtom); + bestOffset = + clientX <= rect.left + rect.width / 2 ? atomOffset : atomOffset + 1; + bestScore = score; + } + + return bestOffset; +} + +export function domPointToLogicalOffset( + container: HTMLElement, + targetNode: Node, + targetOffset: number, +): number { + const boundaryAncestor = findInlineAtomCaretBoundaryAncestor( + targetNode, + container, + ); + if (boundaryAncestor) { + const side = boundaryAncestor.getAttribute( + DATA_ATTRS.inlineAtomCaretSide, + ) as InlineAtomCaretBoundarySide | null; + const host = getInlineAtomHostElement(boundaryAncestor); + if (host && (side === "before" || side === "after")) { + const hostOffset = getOffsetBeforeNode(container, host); + return side === "before" ? hostOffset : hostOffset + 1; + } + } + + const atomAncestor = findInlineAtomAncestor(targetNode, container); + if (atomAncestor) { + const logicalAtom = resolveLogicalInlineAtomUnit(atomAncestor); + const atomOffset = getOffsetBeforeNode(container, logicalAtom); + if (logicalAtom === targetNode || isInlineAtomChipNode(atomAncestor)) { + return targetOffset <= 0 ? atomOffset : atomOffset + 1; + } + return atomOffset + 1; + } + + const resolved = resolveLogicalOffset(container, targetNode, targetOffset); + return resolved ?? getLogicalNodeLength(container); +} + +export function findLogicalDOMPoint( + container: HTMLElement, + offset: number, +): { node: Node; offset: number } { + return findLogicalDOMPointInElement(container, Math.max(0, offset)); +} + +function getLogicalNodeText(node: Node): string { + if (isVirtualInlineDecorationNode(node)) { + return ""; + } + if ( + isInlineAtomCaretBoundaryNode(node) || + hasInlineAtomCaretBoundaryAncestor(node) + ) { + return ""; + } + + if (isInlineAtomHostNode(node)) { + return INLINE_ATOM_REPLACEMENT_TEXT; + } + + if (isInlineAtomChipNode(node)) { + return getInlineAtomHostElement(node) + ? "" + : INLINE_ATOM_REPLACEMENT_TEXT; + } + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ""; + } + + let text = ""; + for (const child of Array.from(node.childNodes)) { + text += getLogicalNodeText(child); + } + return text; +} + +function isVirtualInlineDecorationNode(node: Node | null): node is HTMLElement { + return ( + node instanceof HTMLElement && + node.hasAttribute(VIRTUAL_INLINE_DECORATION_ATTRIBUTE) + ); +} + +function findInlineAtomCaretBoundaryAncestor( + node: Node, + container: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== container) { + if (isInlineAtomCaretBoundaryNode(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function hasInlineAtomCaretBoundaryAncestor(node: Node): boolean { + let current: Node | null = node.parentNode; + while (current) { + if (isInlineAtomCaretBoundaryNode(current)) { + return true; + } + if (isInlineAtomHostNode(current)) { + return false; + } + current = current.parentNode; + } + return false; +} + +function findInlineAtomAncestor( + node: Node, + container: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== container) { + if (isInlineAtomNode(current)) { + return current; + } + current = current.parentNode; + } + return null; +} + +function getOffsetBeforeNode(container: HTMLElement, target: Node): number { + let offset = 0; + let found = false; + + const visit = (node: Node) => { + if (found) { + return; + } + if (node === target) { + found = true; + return; + } + if (node !== container) { + offset += getLogicalNodeLength(node); + return; + } + for (const child of Array.from(node.childNodes)) { + visit(child); + if (found) { + return; + } + } + }; + + visit(container); + return offset; +} + +function resolveLogicalOffset( + current: Node, + targetNode: Node, + targetOffset: number, +): number | null { + if (isVirtualInlineDecorationNode(current)) { + return current === targetNode || current.contains(targetNode) ? 0 : null; + } + if (current === targetNode) { + if (isInlineAtomHostNode(current)) { + return targetOffset <= 0 ? 0 : 1; + } + + if (isInlineAtomChipNode(current)) { + return getInlineAtomHostElement(current) + ? null + : targetOffset <= 0 + ? 0 + : 1; + } + + if (isInlineAtomCaretBoundaryNode(current)) { + return 0; + } + + if (current.nodeType === Node.TEXT_NODE) { + return Math.min(targetOffset, current.textContent?.length ?? 0); + } + + let offset = 0; + const children = Array.from(current.childNodes); + for ( + let index = 0; + index < targetOffset && index < children.length; + index += 1 + ) { + offset += getLogicalNodeLength(children[index]); + } + return offset; + } + + if ( + current.nodeType === Node.TEXT_NODE || + isInlineAtomHostNode(current) || + isInlineAtomChipNode(current) || + isInlineAtomCaretBoundaryNode(current) + ) { + return null; + } + + let offset = 0; + for (const child of Array.from(current.childNodes)) { + const childOffset = resolveLogicalOffset( + child, + targetNode, + targetOffset, + ); + if (childOffset !== null) { + return offset + childOffset; + } + offset += getLogicalNodeLength(child); + } + + return null; +} + +function findLogicalDOMPointInElement( + element: HTMLElement, + offset: number, +): { node: Node; offset: number } { + let remaining = offset; + const children = Array.from(element.childNodes); + + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + const length = getLogicalNodeLength(child); + + if (remaining === 0) { + if (isInlineAtomHostNode(child)) { + const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( + child, + "before", + ); + if (boundaryPoint) { + return boundaryPoint; + } + } + return { node: element, offset: index }; + } + + if (child.nodeType === Node.TEXT_NODE) { + if (remaining <= length) { + return { node: child, offset: remaining }; + } + remaining -= length; + continue; + } + + if (isInlineAtomHostNode(child)) { + if (remaining <= 1) { + const boundaryPoint = getInlineAtomCaretBoundaryTextPoint( + child, + remaining === 0 ? "before" : "after", + ); + if (boundaryPoint) { + return boundaryPoint; + } + return { node: element, offset: index + 1 }; + } + remaining -= 1; + continue; + } + + if (isInlineAtomChipNode(child)) { + if (remaining <= 1) { + return { node: element, offset: index + 1 }; + } + remaining -= 1; + continue; + } + + if (isInlineAtomCaretBoundaryNode(child)) { + continue; + } + + if (remaining <= length && child instanceof HTMLElement) { + return findLogicalDOMPointInElement(child, remaining); + } + + remaining -= length; + } + + return { node: element, offset: children.length }; +} diff --git a/packages/rendering/dom/src/field-editor/inlineAtomModel.ts b/packages/rendering/dom/src/field-editor/inlineAtomModel.ts new file mode 100644 index 0000000..319d7ae --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineAtomModel.ts @@ -0,0 +1,170 @@ +import type { + Editor, + InlineDelta, + InlineNodeDeltaInsert, + SchemaRegistry, +} from "@pen/types"; + +export const INLINE_ATOM_LOGICAL_LENGTH = 1; +export const INLINE_ATOM_REPLACEMENT_TEXT = "\uFFFC"; +export const INLINE_ATOM_CARET_BOUNDARY_TEXT = "\u200B"; + +const ZERO_WIDTH_SPACE = "\u200B"; + +export interface InlineAtomInsert { + type: string; + props: Record; +} + +export interface InlineAtomSnapshot extends InlineAtomInsert { + blockId: string; + offset: number; + text: string; +} + +export interface InlineAtomRange { + start: number; + end: number; +} + +export function resolveInlineAtomInsert( + insert: unknown, +): InlineAtomInsert | null { + if (!insert || typeof insert !== "object") { + return null; + } + + const record = insert as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (!type) { + return null; + } + + if (record.props && typeof record.props === "object") { + return { + type, + props: record.props as Record, + }; + } + + const props: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key !== "type") { + props[key] = value; + } + } + + return { type, props }; +} + +export function resolveInlineAtomDisplayText( + atom: InlineAtomInsert, + registry: Pick, +): string { + const schemaText = registry + .resolveInline(atom.type) + ?.serialize.toMarkdown?.("", atom.props); + if (schemaText) { + return schemaText; + } + + const label = atom.props.label; + if (typeof label === "string" && label.length > 0) { + return label; + } + + const name = atom.props.name; + if (typeof name === "string" && name.length > 0) { + return name; + } + + const id = atom.props.id; + if (typeof id === "string" && id.length > 0) { + return id; + } + + return atom.type; +} + +export function getInlineDeltaLength(delta: InlineDelta): number { + return typeof delta.insert === "string" + ? delta.insert + .replaceAll(ZERO_WIDTH_SPACE, "") + .replaceAll(INLINE_ATOM_REPLACEMENT_TEXT, "").length + : INLINE_ATOM_LOGICAL_LENGTH; +} + +export function getInlineAtomAtOffset( + editor: Editor, + source: { blockId: string; offset: number }, +): InlineAtomSnapshot | null { + const block = editor.getBlock(source.blockId); + if (!block) { + return null; + } + + let offset = 0; + for (const delta of block.inlineDeltas()) { + const length = getInlineDeltaLength(delta); + if (offset === source.offset && typeof delta.insert !== "string") { + return { + blockId: source.blockId, + offset: source.offset, + type: delta.insert.type, + props: { ...delta.insert.props }, + text: resolveInlineAtomDisplayText(delta.insert, editor.schema), + }; + } + + offset += length; + } + + return null; +} + +export function isInlineAtomRange( + ytext: { toDelta(): Array<{ insert?: string | Record }> }, + start: number, + end: number, +): boolean { + const atomRange = getInlineAtomRangeAtOffset(ytext, start); + return atomRange?.end === end; +} + +export function getInlineAtomRangeAtOffset( + ytext: { toDelta(): Array<{ insert?: string | Record }> }, + targetOffset: number, +): InlineAtomRange | null { + if (targetOffset < 0) { + return null; + } + + let offset = 0; + for (const delta of ytext.toDelta()) { + if (delta.insert == null) { + continue; + } + + if (typeof delta.insert === "string") { + offset += delta.insert.length; + continue; + } + + if (offset === targetOffset) { + return { + start: offset, + end: offset + INLINE_ATOM_LOGICAL_LENGTH, + }; + } + offset += INLINE_ATOM_LOGICAL_LENGTH; + } + + return null; +} + +export function getInlineAtomInsertText( + registry: Pick, + atom: InlineNodeDeltaInsert, +): string { + return resolveInlineAtomDisplayText(atom, registry); +} diff --git a/packages/rendering/dom/src/field-editor/inlineInputRules.ts b/packages/rendering/dom/src/field-editor/inlineInputRules.ts new file mode 100644 index 0000000..58c7ae4 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineInputRules.ts @@ -0,0 +1,109 @@ +import { INPUT_RULES_ENGINE_SLOT_KEY, supportsInlineInputRules } from "@pen/types"; +import type { DocumentOp, Editor } from "@pen/types"; +import { matchInlineInputRule } from "../utils/inlineInputRule"; +import type { InlineInputRuleEngine } from "./crdt"; + +export type InlineInputRuleSelectionTarget = { + blockId: string; + anchorOffset: number; + focusOffset: number; +}; + +export function applyInlineInputRule( + editor: Editor, + options: { + blockId: string; + offset: number; + text: string; + }, +): InlineInputRuleSelectionTarget | null { + const { blockId, offset, text } = options; + if (text.length !== 1) { + return null; + } + + const block = editor.getBlock(blockId); + if (!block) { + return null; + } + + const blockSchema = editor.schema.resolve(block.type); + if (!supportsInlineInputRules(blockSchema)) { + return null; + } + + const inputRuleEngine = + editor.internals.getSlot( + INPUT_RULES_ENGINE_SLOT_KEY, + ) ?? null; + const ops = + inputRuleEngine?.tryMatchInline(editor, blockId, text, { offset }) ?? + resolveFallbackInlineInputRule(editor, blockId, block.textContent(), offset, text); + if (!ops) { + return null; + } + + const selectionTarget = resolveInlineSelectionTarget(blockId, ops); + if (!selectionTarget) { + return null; + } + + editor.apply(ops, { origin: "input-rule" }); + return selectionTarget; +} + +function resolveFallbackInlineInputRule( + editor: Editor, + blockId: string, + blockText: string, + offset: number, + text: string, +): DocumentOp[] | null { + const match = matchInlineInputRule(blockText, offset, text); + if (!match) { + return null; + } + + const markType = Object.keys(match.marks)[0]; + if (!markType || !editor.schema.resolveInline(markType)) { + return null; + } + + return [ + { + type: "delete-text", + blockId, + offset: match.deleteRange.start, + length: match.deleteRange.end - match.deleteRange.start, + }, + { + type: "insert-text", + blockId, + offset: match.deleteRange.start, + text: match.text, + marks: match.marks, + }, + ]; +} + +function resolveInlineSelectionTarget( + blockId: string, + ops: DocumentOp[], +): InlineInputRuleSelectionTarget | null { + let nextOffset: number | null = null; + for (const op of ops) { + if (op.type === "insert-text" && op.blockId === blockId) { + nextOffset = op.offset + op.text.length; + } + } + + if (nextOffset == null) { + return null; + } + + return { + blockId, + anchorOffset: nextOffset, + focusOffset: nextOffset, + }; +} diff --git a/packages/rendering/dom/src/field-editor/inlineTextTransaction.ts b/packages/rendering/dom/src/field-editor/inlineTextTransaction.ts new file mode 100644 index 0000000..27308be --- /dev/null +++ b/packages/rendering/dom/src/field-editor/inlineTextTransaction.ts @@ -0,0 +1,148 @@ +import type { DocumentOp } from "@pen/types"; +import type { ActiveCellCoord } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; + +export type InlineTextRange = { + start: number; + end: number; +}; + +export type InlineTextDiffOp = + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number }; + +export type InlineTextSelectionTarget = { + blockId: string; + anchorOffset: number; + focusOffset: number; + cell?: { + row: number; + col: number; + }; +}; + +export function buildInlineTextEditTransaction(options: { + blockId: string; + range: InlineTextRange; + text: string; + marks?: Record; + cellCoord?: ActiveCellCoord | null; +}): { + ops: DocumentOp[]; + selection: InlineTextSelectionTarget; +} { + const { blockId, range, text, marks, cellCoord } = options; + const ops: DocumentOp[] = []; + const nextOffset = range.start + text.length; + + if (range.end > range.start) { + ops.push( + cellCoord + ? { + type: "delete-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: range.start, + length: range.end - range.start, + } + : { + type: "delete-text", + blockId, + offset: range.start, + length: range.end - range.start, + }, + ); + } + + if (text.length > 0) { + ops.push( + cellCoord + ? { + type: "insert-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: range.start, + text, + } + : { + type: "insert-text", + blockId, + offset: range.start, + text, + marks, + }, + ); + } + + return { + ops, + selection: { + blockId, + anchorOffset: nextOffset, + focusOffset: nextOffset, + cell: cellCoord + ? { row: cellCoord.row, col: cellCoord.col } + : undefined, + }, + }; +} + +export function buildInlineTextDiffOps(options: { + blockId: string; + diff: readonly InlineTextDiffOp[]; + ytext: FieldEditorTextLike; + resolveInsertMarks: ( + ytext: FieldEditorTextLike, + offset: number, + ) => Record | undefined; + cellCoord?: ActiveCellCoord | null; +}): DocumentOp[] { + const { blockId, diff, ytext, resolveInsertMarks, cellCoord } = options; + const ops: DocumentOp[] = []; + + for (const op of diff) { + if (op.type === "delete") { + ops.push( + cellCoord + ? { + type: "delete-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: op.offset, + length: op.length, + } + : { + type: "delete-text", + blockId, + offset: op.offset, + length: op.length, + }, + ); + continue; + } + + ops.push( + cellCoord + ? { + type: "insert-table-cell-text", + blockId, + row: cellCoord.row, + col: cellCoord.col, + offset: op.offset, + text: op.text, + } + : { + type: "insert-text", + blockId, + offset: op.offset, + text: op.text, + marks: resolveInsertMarks(ytext, op.offset), + }, + ); + } + + return ops; +} diff --git a/packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts b/packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts new file mode 100644 index 0000000..659e503 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/keyBindingShortcuts.ts @@ -0,0 +1,213 @@ +import type { Editor, KeyBindingContext } from "@pen/types"; +import { + COLLECT_KEY_BINDINGS_SLOT_KEY, + usesInlineTextSelection, +} from "@pen/types"; +import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; + +export function tryHandleHistoryOverrideBinding( + editor: Editor, + event: KeyboardEvent, +): boolean { + if (!isUndoShortcut(event) && !isRedoShortcut(event)) { + return false; + } + + const bindings = collectKeyBindings(editor); + for (const binding of bindings) { + if ( + matchesBindingContext(editor, binding.context) && + matchesKey(binding.key, event) && + binding.handler(editor, event) + ) { + return true; + } + } + + return false; +} + +export function getDocumentTextRange(editor: Editor): { + start: { blockId: string; offset: number }; + end: { blockId: string; offset: number }; + focusBlockId: string; +} | null { + const blockOrder = editor.documentState.blockOrder; + const firstBlockId = blockOrder[0]; + const lastBlockId = blockOrder[blockOrder.length - 1]; + if (!firstBlockId || !lastBlockId) { + return null; + } + + const focusBlockId = + blockOrder.find((blockId) => { + const block = editor.getBlock(blockId); + if (!block) return false; + const schema = editor.schema.resolve(block.type); + return usesInlineTextSelection(schema); + }) ?? firstBlockId; + + return { + start: { blockId: firstBlockId, offset: 0 }, + end: { + blockId: lastBlockId, + offset: getEditorBlockSelectionLength(editor, lastBlockId), + }, + focusBlockId, + }; +} + +export function collectKeyBindings(editor: Editor): ReadonlyArray<{ + key: string; + context?: KeyBindingContext; + handler: (editor: Editor, event: KeyboardEvent) => boolean; +}> { + const collect = + editor.internals.getSlot< + (registry: Editor["schema"]) => ReadonlyArray<{ + key: string; + context?: KeyBindingContext; + handler: (editor: Editor, event: KeyboardEvent) => boolean; + }> + >(COLLECT_KEY_BINDINGS_SLOT_KEY) ?? null; + return collect?.(editor.schema) ?? []; +} + +export function matchesBindingContext( + editor: Editor, + context: KeyBindingContext | undefined, +): boolean { + if (!context) return true; + + const selection = editor.selection; + const activeBlock = getActiveBlock(editor); + + if ( + context.blockType && + (!activeBlock || !context.blockType.includes(activeBlock.type)) + ) { + return false; + } + + if (context.hasSelection !== undefined) { + const hasSelection = + selection?.type === "text" + ? !selection.isCollapsed + : selection !== null; + if (hasSelection !== context.hasSelection) { + return false; + } + } + + if (context.collapsed !== undefined) { + const isCollapsed = selection?.type === "text" && selection.isCollapsed; + if (isCollapsed !== context.collapsed) { + return false; + } + } + + if ( + context.withinLayout && + (!activeBlock || !isWithinLayout(activeBlock, context.withinLayout)) + ) { + return false; + } + + return true; +} + +function getActiveBlock(editor: Editor) { + const selection = editor.selection; + if (!selection) return null; + + if (selection.type === "text") { + return editor.getBlock(selection.anchor.blockId); + } + + if (selection.type === "block") { + const blockId = selection.blockIds[0]; + return blockId ? editor.getBlock(blockId) : null; + } + + if (selection.type === "cell") { + return editor.getBlock(selection.blockId); + } + + return null; +} + +function isWithinLayout( + block: NonNullable>, + allowedLayoutTypes: readonly string[], +): boolean { + let parent = block.layoutParent(); + while (parent) { + if (allowedLayoutTypes.includes(parent.type)) { + return true; + } + parent = parent.layoutParent(); + } + + return false; +} + +export function matchesKey(pattern: string, event: KeyboardEvent): boolean { + const parts = pattern.split("-").map((part) => part.toLowerCase()); + const key = parts.pop()?.toLowerCase() ?? ""; + + const needsCtrl = parts.includes("ctrl"); + const needsMeta = parts.includes("meta"); + const needsMod = parts.includes("mod"); + const needsShift = parts.includes("shift"); + const needsAlt = parts.includes("alt"); + + const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.platform ?? ""); + + const allowCtrl = needsCtrl || (needsMod && !isMac); + const allowMeta = needsMeta || (needsMod && isMac); + + const modMatch = needsMod ? (isMac ? event.metaKey : event.ctrlKey) : true; + const ctrlMatch = allowCtrl ? event.ctrlKey : !event.ctrlKey; + const metaMatch = allowMeta ? event.metaKey : !event.metaKey; + const shiftMatch = needsShift ? event.shiftKey : !event.shiftKey; + const altMatch = needsAlt ? event.altKey : !event.altKey; + + return ( + modMatch && + ctrlMatch && + metaMatch && + shiftMatch && + altMatch && + event.key.toLowerCase() === key + ); +} + +export function isSelectAllShortcut(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "a" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +export function isUndoShortcut(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "z" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +export function isRedoShortcut(event: KeyboardEvent): boolean { + const key = event.key.toLowerCase(); + const usesMod = event.metaKey || event.ctrlKey; + return ( + usesMod && + !event.altKey && + ((key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey)) + ); +} diff --git a/packages/rendering/dom/src/field-editor/keyHandling.ts b/packages/rendering/dom/src/field-editor/keyHandling.ts index 52ae670..1ccae1e 100644 --- a/packages/rendering/dom/src/field-editor/keyHandling.ts +++ b/packages/rendering/dom/src/field-editor/keyHandling.ts @@ -1,11 +1,5 @@ -import { - getInlineCompletionController, -} from "@pen/core"; -import type { Editor, KeyBindingContext } from "@pen/types"; -import { - COLLECT_KEY_BINDINGS_SLOT_KEY, - usesInlineTextSelection, -} from "@pen/types"; +import { getInlineCompletionController } from "@pen/core"; +import type { Editor } from "@pen/types"; import type { FieldEditorKeyboardController } from "./controller"; import { applyDeleteBehavior, @@ -14,8 +8,18 @@ import { moveCaretAcrossBlocks, type SelectionRange, } from "./commands"; -import { getEditorBlockSelectionLength } from "../utils/blockSelectionSemantics"; import { getAutocompleteController } from "../utils/autocompleteController"; +import { selectInlineAtomWithArrowKey } from "./keyHandlingInlineAtoms"; +import { + collectKeyBindings, + getDocumentTextRange, + isRedoShortcut, + isSelectAllShortcut, + isUndoShortcut, + matchesBindingContext, + matchesKey, + tryHandleHistoryOverrideBinding, +} from "./keyBindingShortcuts"; export function handleFieldEditorKeyDown(options: { event: KeyboardEvent; @@ -24,6 +28,7 @@ export function handleFieldEditorKeyDown(options: { ytext: { length: number; toString(): string; + toDelta(): Array<{ insert?: string | Record }>; insert(offset: number, text: string): void; delete(offset: number, length: number): void; }; @@ -38,10 +43,7 @@ export function handleFieldEditorKeyDown(options: { autocomplete?.dismiss("typing"); } - if ( - !event.defaultPrevented && - handleHistoryShortcut(editor, event) - ) { + if (!event.defaultPrevented && handleHistoryShortcut(editor, event)) { return true; } @@ -152,7 +154,11 @@ export function handleFieldEditorKeyDown(options: { if (autocomplete?.hasVisibleSuggestion()) { return autocomplete.acceptVisibleSuggestion(); } - return inlineCompletion.acceptSuggestion(); + const accepted = inlineCompletion.acceptSuggestion(); + if (accepted) { + syncAcceptedInlineCompletionSelection(editor, fieldEditor); + } + return accepted; } if (!event.shiftKey) { @@ -210,11 +216,28 @@ export function handleFieldEditorKeyDown(options: { if ( (event.key === "ArrowLeft" || event.key === "ArrowUp") && - !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey ) { + if ( + event.key === "ArrowLeft" && + selectInlineAtomWithArrowKey({ + blockId, + editor, + event, + fieldEditor, + range, + ytext, + }) + ) { + return true; + } + + if (event.shiftKey) { + return false; + } + const target = moveCaretAcrossBlocks(editor, { blockId, ytext, @@ -238,11 +261,28 @@ export function handleFieldEditorKeyDown(options: { if ( (event.key === "ArrowRight" || event.key === "ArrowDown") && - !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey ) { + if ( + event.key === "ArrowRight" && + selectInlineAtomWithArrowKey({ + blockId, + editor, + event, + fieldEditor, + range, + ytext, + }) + ) { + return true; + } + + if (event.shiftKey) { + return false; + } + const target = moveCaretAcrossBlocks(editor, { blockId, ytext, @@ -267,6 +307,30 @@ export function handleFieldEditorKeyDown(options: { return handleEditorKeyBindings(editor, event, { includeSelectAll: false }); } + +function syncAcceptedInlineCompletionSelection( + editor: Editor, + fieldEditor: FieldEditorKeyboardController, +): void { + const selection = editor.selection; + if ( + selection?.type !== "text" || + !selection.isCollapsed || + selection.isMultiBlock + ) { + return; + } + + const blockId = selection.focus.blockId; + const offset = selection.focus.offset; + if (typeof fieldEditor.commitProgrammaticTextSelection === "function") { + fieldEditor.commitProgrammaticTextSelection(blockId, offset, offset); + return; + } + + fieldEditor.activateTextSelection(blockId, offset, offset); +} + function shouldDismissAutocompleteOnKeyDown( event: KeyboardEvent, autocomplete: ReturnType, @@ -359,210 +423,3 @@ export function handleHistoryShortcut( return false; } - -function tryHandleHistoryOverrideBinding( - editor: Editor, - event: KeyboardEvent, -): boolean { - if (!isUndoShortcut(event) && !isRedoShortcut(event)) { - return false; - } - - const bindings = collectKeyBindings(editor); - for (const binding of bindings) { - if ( - matchesBindingContext(editor, binding.context) && - matchesKey(binding.key, event) && - binding.handler(editor, event) - ) { - return true; - } - } - - return false; -} - -function getDocumentTextRange(editor: Editor): { - start: { blockId: string; offset: number }; - end: { blockId: string; offset: number }; - focusBlockId: string; -} | null { - const blockOrder = editor.documentState.blockOrder; - const firstBlockId = blockOrder[0]; - const lastBlockId = blockOrder[blockOrder.length - 1]; - if (!firstBlockId || !lastBlockId) { - return null; - } - - const focusBlockId = - blockOrder.find((blockId) => { - const block = editor.getBlock(blockId); - if (!block) return false; - const schema = editor.schema.resolve(block.type); - return usesInlineTextSelection(schema); - }) ?? firstBlockId; - - return { - start: { blockId: firstBlockId, offset: 0 }, - end: { - blockId: lastBlockId, - offset: getEditorBlockSelectionLength(editor, lastBlockId), - }, - focusBlockId, - }; -} - -function collectKeyBindings(editor: Editor): ReadonlyArray<{ - key: string; - context?: KeyBindingContext; - handler: (editor: Editor, event: KeyboardEvent) => boolean; -}> { - const collect = - editor.internals.getSlot< - (registry: Editor["schema"]) => ReadonlyArray<{ - key: string; - context?: KeyBindingContext; - handler: (editor: Editor, event: KeyboardEvent) => boolean; - }> - >(COLLECT_KEY_BINDINGS_SLOT_KEY) ?? null; - return collect?.(editor.schema) ?? []; -} - -function matchesBindingContext( - editor: Editor, - context: KeyBindingContext | undefined, -): boolean { - if (!context) return true; - - const selection = editor.selection; - const activeBlock = getActiveBlock(editor); - - if ( - context.blockType && - (!activeBlock || !context.blockType.includes(activeBlock.type)) - ) { - return false; - } - - if (context.hasSelection !== undefined) { - const hasSelection = - selection?.type === "text" - ? !selection.isCollapsed - : selection !== null; - if (hasSelection !== context.hasSelection) { - return false; - } - } - - if (context.collapsed !== undefined) { - const isCollapsed = selection?.type === "text" && selection.isCollapsed; - if (isCollapsed !== context.collapsed) { - return false; - } - } - - if ( - context.withinLayout && - (!activeBlock || !isWithinLayout(activeBlock, context.withinLayout)) - ) { - return false; - } - - return true; -} - -function getActiveBlock(editor: Editor) { - const selection = editor.selection; - if (!selection) return null; - - if (selection.type === "text") { - return editor.getBlock(selection.anchor.blockId); - } - - if (selection.type === "block") { - const blockId = selection.blockIds[0]; - return blockId ? editor.getBlock(blockId) : null; - } - - if (selection.type === "cell") { - return editor.getBlock(selection.blockId); - } - - return null; -} - -function isWithinLayout( - block: NonNullable>, - allowedLayoutTypes: readonly string[], -): boolean { - let parent = block.layoutParent(); - while (parent) { - if (allowedLayoutTypes.includes(parent.type)) { - return true; - } - parent = parent.layoutParent(); - } - - return false; -} - -function matchesKey(pattern: string, event: KeyboardEvent): boolean { - const parts = pattern.split("-").map((part) => part.toLowerCase()); - const key = parts.pop()?.toLowerCase() ?? ""; - - const needsCtrl = parts.includes("ctrl"); - const needsMeta = parts.includes("meta"); - const needsMod = parts.includes("mod"); - const needsShift = parts.includes("shift"); - const needsAlt = parts.includes("alt"); - - const isMac = - typeof navigator !== "undefined" && - /Mac|iPhone|iPad/.test(navigator.platform ?? ""); - - const allowCtrl = needsCtrl || (needsMod && !isMac); - const allowMeta = needsMeta || (needsMod && isMac); - - const modMatch = needsMod ? (isMac ? event.metaKey : event.ctrlKey) : true; - const ctrlMatch = allowCtrl ? event.ctrlKey : !event.ctrlKey; - const metaMatch = allowMeta ? event.metaKey : !event.metaKey; - const shiftMatch = needsShift ? event.shiftKey : !event.shiftKey; - const altMatch = needsAlt ? event.altKey : !event.altKey; - - return ( - modMatch && - ctrlMatch && - metaMatch && - shiftMatch && - altMatch && - event.key.toLowerCase() === key - ); -} - -function isSelectAllShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "a" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -function isUndoShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "z" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -function isRedoShortcut(event: KeyboardEvent): boolean { - const key = event.key.toLowerCase(); - const usesMod = event.metaKey || event.ctrlKey; - return ( - usesMod && - !event.altKey && - ((key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey)) - ); -} diff --git a/packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts b/packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts new file mode 100644 index 0000000..149963c --- /dev/null +++ b/packages/rendering/dom/src/field-editor/keyHandlingInlineAtoms.ts @@ -0,0 +1,128 @@ +import type { Editor } from "@pen/types"; +import type { FieldEditorKeyboardController } from "./controller"; +import { + normalizeInlineRange, + type SelectionRange, +} from "./commands"; +import { + getInlineAtomRangeAtOffset, + isInlineAtomRange, +} from "./inlineAtomModel"; + +export function selectInlineAtomWithArrowKey(options: { + blockId: string; + editor: Editor; + event: KeyboardEvent; + fieldEditor: FieldEditorKeyboardController; + range: SelectionRange | null; + ytext: { + length: number; + toString(): string; + toDelta(): Array<{ insert?: string | Record }>; + }; +}): boolean { + const { blockId, editor, event, fieldEditor, ytext } = options; + const range = normalizeInlineRange(ytext, options.range); + if (!range) { + return false; + } + + const direction = event.key === "ArrowLeft" ? "previous" : "next"; + if (event.shiftKey) { + return extendInlineAtomSelectionWithArrowKey({ + blockId, + direction, + editor, + fieldEditor, + range, + ytext, + }); + } + + if (range.start !== range.end) { + if (!isInlineAtomRange(ytext, range.start, range.end)) { + return false; + } + const offset = direction === "previous" ? range.start : range.end; + fieldEditor.activateTextSelection(blockId, offset, offset); + return true; + } + + const atomOffset = direction === "previous" ? range.start - 1 : range.start; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + + fieldEditor.activateTextSelection(blockId, atomRange.start, atomRange.end); + return true; +} + +function extendInlineAtomSelectionWithArrowKey(options: { + blockId: string; + direction: "previous" | "next"; + editor: Editor; + fieldEditor: FieldEditorKeyboardController; + range: SelectionRange; + ytext: { + toDelta(): Array<{ insert?: string | Record }>; + }; +}): boolean { + const { blockId, direction, editor, fieldEditor, range, ytext } = options; + const selection = editor.selection; + if ( + selection?.type === "text" && + !selection.isCollapsed && + !selection.isMultiBlock && + selection.anchor.blockId === blockId && + selection.focus.blockId === blockId + ) { + const focusAtomOffset = + direction === "previous" + ? selection.focus.offset - 1 + : selection.focus.offset; + const focusAtomRange = getInlineAtomRangeAtOffset( + ytext, + focusAtomOffset, + ); + if (focusAtomRange) { + const nextFocusOffset = + direction === "previous" + ? focusAtomRange.start + : focusAtomRange.end; + fieldEditor.activateTextSelection( + blockId, + selection.anchor.offset, + nextFocusOffset, + ); + return true; + } + } + + if (range.start === range.end) { + const atomOffset = + direction === "previous" ? range.start - 1 : range.end; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + const anchorOffset = + direction === "previous" ? atomRange.end : atomRange.start; + const focusOffset = + direction === "previous" ? atomRange.start : atomRange.end; + fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); + return true; + } + + const atomOffset = direction === "previous" ? range.start - 1 : range.end; + const atomRange = getInlineAtomRangeAtOffset(ytext, atomOffset); + if (!atomRange) { + return false; + } + + const anchorOffset = + direction === "previous" ? atomRange.start : range.start; + const focusOffset = direction === "previous" ? range.end : atomRange.end; + fieldEditor.activateTextSelection(blockId, anchorOffset, focusOffset); + return true; +} diff --git a/packages/rendering/dom/src/field-editor/pendingMarkController.ts b/packages/rendering/dom/src/field-editor/pendingMarkController.ts new file mode 100644 index 0000000..8e2ce20 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/pendingMarkController.ts @@ -0,0 +1,112 @@ +import type { Editor } from "@pen/types"; +import { resolveMarksAtPosition } from "./markBoundary"; +import type { FieldEditorTextLike } from "./crdt"; + +type PendingMarkControllerOptions = { + editor: Editor; + getFocusBlockId: () => string | null; + getYText: (blockId: string) => FieldEditorTextLike | null; + emitStateChange: () => void; +}; + +export class PendingMarkController { + private readonly editor: Editor; + private readonly getFocusBlockId: () => string | null; + private readonly getYText: (blockId: string) => FieldEditorTextLike | null; + private readonly emitStateChange: () => void; + private pendingMarks: Record = {}; + + constructor(options: PendingMarkControllerOptions) { + this.editor = options.editor; + this.getFocusBlockId = options.getFocusBlockId; + this.getYText = options.getYText; + this.emitStateChange = options.emitStateChange; + } + + getSnapshot(): Readonly> { + return this.pendingMarks; + } + + reset(): void { + this.pendingMarks = {}; + } + + clear(silent = false): void { + if (Object.keys(this.pendingMarks).length === 0) return; + this.pendingMarks = {}; + if (!silent) { + this.emitStateChange(); + } + } + + toggle(markType: string, isEditing: boolean, inputMode: string): boolean { + if (!isEditing || inputMode !== "richtext") return false; + + const baseMarks = this.resolveBaseInsertMarks(); + const baseValue = baseMarks[markType]; + const effectiveMarks = this.applyPendingMarks(baseMarks); + const nextValue = effectiveMarks[markType] != null ? null : true; + const nextPendingMarks = { ...this.pendingMarks }; + + if ((baseValue ?? null) === nextValue) { + delete nextPendingMarks[markType]; + } else { + nextPendingMarks[markType] = nextValue; + } + + this.pendingMarks = nextPendingMarks; + this.emitStateChange(); + return true; + } + + resolveInsertMarks( + ytext: FieldEditorTextLike, + offset: number, + ): Record | undefined { + const baseMarks = + resolveMarksAtPosition(ytext, offset, this.editor.schema) ?? {}; + const resolved = this.applyPendingMarks(baseMarks); + const insertMarks: Record = { ...resolved }; + + for (const [markType, value] of Object.entries(this.pendingMarks)) { + if (value == null && markType in baseMarks) { + insertMarks[markType] = null; + } + } + + return Object.keys(insertMarks).length > 0 ? insertMarks : undefined; + } + + private resolveBaseInsertMarks(): Record { + const selection = this.editor.selection; + if (!this.getFocusBlockId() || selection?.type !== "text") { + return {}; + } + + const blockId = selection.focus.blockId; + const ytext = this.getYText(blockId); + if (!ytext) return {}; + + return ( + resolveMarksAtPosition( + ytext, + selection.focus.offset, + this.editor.schema, + ) ?? {} + ); + } + + private applyPendingMarks( + baseMarks: Record, + ): Record { + const nextMarks = { ...baseMarks }; + for (const [markType, value] of Object.entries(this.pendingMarks)) { + if (value == null) { + delete nextMarks[markType]; + } else { + nextMarks[markType] = value; + } + } + return nextMarks; + } +} diff --git a/packages/rendering/dom/src/field-editor/reconciler.ts b/packages/rendering/dom/src/field-editor/reconciler.ts index 75b3080..7f7e408 100644 --- a/packages/rendering/dom/src/field-editor/reconciler.ts +++ b/packages/rendering/dom/src/field-editor/reconciler.ts @@ -1,464 +1,10 @@ -import type { InlineDecoration, SchemaRegistry } from "@pen/types"; -import { sortDeltaAttributes } from "@pen/core"; -import type { FieldEditorDelta, FieldEditorTextLike } from "./crdt"; -import { - applyInlineDecorationsToDeltas, - INLINE_DECORATION_ATTRIBUTE_KEY, -} from "../utils/inlineDecorations"; - -// ── Fast path: event-driven delta application ────────────── - -export function applyDeltaToDOM( - delta: readonly FieldEditorDelta[], - element: HTMLElement, - _registry: SchemaRegistry, -): boolean { - let childIndex = 0; - let textOffset = 0; - - for (const entry of delta) { - if (entry.retain != null) { - let remaining = entry.retain; - while (remaining > 0 && childIndex < element.childNodes.length) { - const span = element.childNodes[childIndex]; - const spanText = span.textContent ?? ""; - const available = spanText.length - textOffset; - - if (remaining < available) { - textOffset += remaining; - remaining = 0; - } else { - remaining -= available; - childIndex++; - textOffset = 0; - } - } - if (remaining > 0) return false; - - if (entry.attributes != null) { - return false; - } - } else if (typeof entry.insert === "string") { - const text = entry.insert; - - if (!entry.attributes) { - const span = element.childNodes[childIndex]; - if (span && span.nodeType === Node.TEXT_NODE) { - const existing = span.textContent ?? ""; - span.textContent = - existing.slice(0, textOffset) + text + existing.slice(textOffset); - textOffset += text.length; - } else if (span && span.nodeType === Node.ELEMENT_NODE) { - const leaf = deepLeafText(span); - if (!leaf) return false; - const existing = leaf.textContent ?? ""; - leaf.textContent = - existing.slice(0, textOffset) + text + existing.slice(textOffset); - textOffset += text.length; - } else { - element.appendChild(document.createTextNode(text)); - childIndex = element.childNodes.length - 1; - textOffset = text.length; - } - } else { - if (textOffset === 0) { - const node = createMarkedNode(text, entry.attributes, _registry); - const ref = element.childNodes[childIndex] ?? null; - element.insertBefore(node, ref); - childIndex++; - } else { - return false; - } - } - } else if (entry.delete != null) { - let remaining = entry.delete; - while (remaining > 0 && childIndex < element.childNodes.length) { - const span = element.childNodes[childIndex]; - const leaf = - span.nodeType === Node.TEXT_NODE ? span : deepLeafText(span); - if (!leaf) return false; - const existing = leaf.textContent ?? ""; - const available = existing.length - textOffset; - - if (remaining < available) { - leaf.textContent = - existing.slice(0, textOffset) + - existing.slice(textOffset + remaining); - remaining = 0; - } else { - if (textOffset === 0) { - element.removeChild(span); - remaining -= existing.length; - } else { - leaf.textContent = existing.slice(0, textOffset); - remaining -= available; - childIndex++; - textOffset = 0; - } - } - } - } - } - return true; -} - -function deepLeafText(node: Node): Text | null { - if (node.nodeType === Node.TEXT_NODE) return node as Text; - for (let i = 0; i < node.childNodes.length; i++) { - const found = deepLeafText(node.childNodes[i]); - if (found) return found; - } - return null; -} - -// ── Full reconciliation fallback ─────────────────────────── - -export function fullReconcileToDOM( - ytext: FieldEditorTextLike, - element: HTMLElement, - registry: SchemaRegistry, - options?: { - preserveSelection?: boolean; - inlineDecorations?: readonly InlineDecoration[]; - }, -): void { - const textDeltas = ytext - .toDelta() - .filter( - (delta): delta is FieldEditorDelta & { insert: string } => - typeof delta.insert === "string", - ); - const renderedDeltas = - options?.inlineDecorations && options.inlineDecorations.length > 0 - ? applyInlineDecorationsToDeltas(textDeltas, options.inlineDecorations) - : textDeltas; - fullReconcileDeltasToDOM(renderedDeltas, element, registry, options); -} - -export function fullReconcileDeltasToDOM( - deltas: FieldEditorDelta[], - element: HTMLElement, - registry: SchemaRegistry, - options?: { preserveSelection?: boolean }, -): void { - const orderedDeltas = deltas.map((d) => { - if (!d.attributes || Object.keys(d.attributes).length < 2) return d; - return { ...d, attributes: sortDeltaAttributes(d.attributes, registry) }; - }); - - const preserveSelection = options?.preserveSelection ?? true; - const savedSel = preserveSelection ? saveSelection(element) : null; - - const fragment = document.createDocumentFragment(); - for (const delta of orderedDeltas) { - if (typeof delta.insert !== "string") continue; - let node: Node = document.createTextNode(delta.insert); - if (delta.attributes) { - node = wrapWithMarks(node, delta.attributes, registry); - } - fragment.appendChild(node); - } - - patchDOM(element, fragment); - if (savedSel) { - restoreSelection(element, savedSel); - } -} - -// ── Mark wrapping ────────────────────────────────────────── - -function wrapWithMarks( - node: Node, - attributes: Record, - registry: SchemaRegistry, -): Node { - let wrapped = node; - const decorationAttributes = - isDecorationAttributesValue(attributes[INLINE_DECORATION_ATTRIBUTE_KEY]) - ? attributes[INLINE_DECORATION_ATTRIBUTE_KEY] - : null; - - const entries = Object.entries(attributes) - .filter(([key]) => key !== INLINE_DECORATION_ATTRIBUTE_KEY) - .filter(([_, v]) => v !== null && v !== false) - .sort(([a], [b]) => { - const schemaA = registry.resolveInline(a); - const schemaB = registry.resolveInline(b); - return (schemaA?.priority ?? 0) - (schemaB?.priority ?? 0); - }); - - for (const [markType, markProps] of entries) { - const el = createMarkElement(markType, markProps); - el.appendChild(wrapped); - wrapped = el; - } - - if (decorationAttributes) { - const el = createMarkElement( - INLINE_DECORATION_ATTRIBUTE_KEY, - decorationAttributes, - ); - el.appendChild(wrapped); - wrapped = el; - } - - return wrapped; -} - -function createMarkedNode( - text: string, - attributes: Record, - registry: SchemaRegistry, -): Node { - let node: Node = document.createTextNode(text); - return wrapWithMarks(node, attributes, registry); -} - -function createMarkElement(markType: string, props: unknown): HTMLElement { - switch (markType) { - case INLINE_DECORATION_ATTRIBUTE_KEY: { - const span = document.createElement("span"); - applyElementAttributes(span, props); - return span; - } - case "bold": - return document.createElement("strong"); - case "italic": - return document.createElement("em"); - case "underline": - return document.createElement("u"); - case "strikethrough": - return document.createElement("s"); - case "code": - return document.createElement("code"); - case "link": { - const a = document.createElement("a"); - if (typeof props === "object" && props !== null) { - const p = props as Record; - if (p.href) a.href = p.href as string; - if (p.title) a.title = p.title as string; - } - return a; - } - case "highlight": { - const mark = document.createElement("mark"); - if (typeof props === "object" && props !== null) { - const p = props as Record; - if (p.color) mark.style.backgroundColor = p.color as string; - } - return mark; - } - case "suggestion": { - const span = document.createElement("span"); - span.dataset.markType = markType; - - if (typeof props === "object" && props !== null) { - const p = props as Record; - const suggestionId = - typeof p.id === "string" && p.id.length > 0 ? p.id : null; - const suggestionAction = - p.action === "delete" ? "delete" : "insert"; - - if (suggestionId) { - span.dataset.suggestionId = suggestionId; - } - - span.dataset.suggestionAction = suggestionAction; - span.classList.add( - suggestionAction === "delete" - ? "pen-suggestion-delete" - : "pen-suggestion-insert", - ); - } - - return span; - } - default: { - const span = document.createElement("span"); - span.dataset.markType = markType; - return span; - } - } -} - -function applyElementAttributes(element: HTMLElement, props: unknown): void { - if (!isDecorationAttributesValue(props)) { - return; - } - - for (const [key, value] of Object.entries(props)) { - if (value === null || value === false || value === undefined) { - continue; - } - if (key === "class" && typeof value === "string") { - element.className = value; - continue; - } - if (key === "style" && typeof value === "string") { - element.style.cssText = value; - continue; - } - if (value === true) { - element.setAttribute(key, ""); - continue; - } - element.setAttribute(key, String(value)); - } -} - -function isDecorationAttributesValue( - value: unknown, -): value is Record { - return typeof value === "object" && value !== null; -} - -// ── DOM patching ─────────────────────────────────────────── - -function patchDOM(target: HTMLElement, source: DocumentFragment): void { - const targetNodes = Array.from(target.childNodes); - const sourceNodes = Array.from(source.childNodes); - - let ti = 0; - let si = 0; - - while (si < sourceNodes.length) { - const sourceNode = sourceNodes[si]; - - if (ti < targetNodes.length) { - const targetNode = targetNodes[ti]; - - if (nodesStructurallyEqual(targetNode, sourceNode)) { - updateTextContent(targetNode, sourceNode); - ti++; - si++; - } else { - const cloned = sourceNode.cloneNode(true); - target.replaceChild(cloned, targetNode); - ti++; - si++; - } - } else { - target.appendChild(sourceNode.cloneNode(true)); - si++; - } - } - - while (target.childNodes.length > sourceNodes.length) { - target.removeChild(target.lastChild!); - } -} - -function nodesStructurallyEqual(a: Node, b: Node): boolean { - if (a.nodeType !== b.nodeType) return false; - if (a.nodeType === Node.TEXT_NODE) return true; - if (a.nodeType === Node.ELEMENT_NODE) { - const elA = a as Element; - const elB = b as Element; - if (elA.tagName !== elB.tagName) return false; - if (elA.attributes.length !== elB.attributes.length) return false; - for (let i = 0; i < elA.attributes.length; i++) { - const attr = elA.attributes[i]; - if (elB.getAttribute(attr.name) !== attr.value) return false; - } - if (elA.childNodes.length !== elB.childNodes.length) return false; - for (let i = 0; i < elA.childNodes.length; i++) { - if (!nodesStructurallyEqual(elA.childNodes[i], elB.childNodes[i])) - return false; - } - return true; - } - return true; -} - -function updateTextContent(target: Node, source: Node): void { - if (target.nodeType === Node.TEXT_NODE && source.nodeType === Node.TEXT_NODE) { - if (target.textContent !== source.textContent) { - target.textContent = source.textContent; - } - return; - } - if ( - target.nodeType === Node.ELEMENT_NODE && - source.nodeType === Node.ELEMENT_NODE - ) { - for (let i = 0; i < target.childNodes.length; i++) { - updateTextContent(target.childNodes[i], source.childNodes[i]); - } - } -} - -// ── Selection save/restore ───────────────────────────────── - -export interface SavedSelection { - anchorOffset: number; - focusOffset: number; -} - -export function saveSelection(element: HTMLElement): SavedSelection | null { - const sel = typeof window !== "undefined" ? window.getSelection() : null; - if (!sel || sel.rangeCount === 0) return null; - - const anchorOffset = computeCharacterOffset(element, sel.anchorNode, sel.anchorOffset); - const focusOffset = computeCharacterOffset(element, sel.focusNode, sel.focusOffset); - - return { anchorOffset, focusOffset }; -} - -export function restoreSelection( - element: HTMLElement, - saved: SavedSelection | null, -): void { - if (!saved) return; - try { - const sel = window.getSelection(); - if (!sel) return; - - const anchor = findPositionInDOM(element, saved.anchorOffset); - const focus = findPositionInDOM(element, saved.focusOffset); - if (!anchor || !focus) return; - - sel.setBaseAndExtent( - anchor.node, - anchor.offset, - focus.node, - focus.offset, - ); - } catch { - // Selection restoration can fail if DOM structure changed - } -} - -function computeCharacterOffset( - root: HTMLElement, - node: Node | null, - offset: number, -): number { - if (!node) return 0; - let charOffset = 0; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - if (current === node) { - return charOffset + offset; - } - charOffset += (current.textContent ?? "").length; - } - return charOffset + offset; -} - -function findPositionInDOM( - root: HTMLElement, - charOffset: number, -): { node: Node; offset: number } | null { - let remaining = charOffset; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - const len = (current.textContent ?? "").length; - if (remaining <= len) { - return { node: current, offset: remaining }; - } - remaining -= len; - } - return null; -} +export { applyDeltaToDOM } from "./reconcilerDeltaApply"; +export { + fullReconcileDeltasToDOM, + fullReconcileToDOM, +} from "./reconcilerFull"; +export { + restoreSelection, + saveSelection, + type SavedSelection, +} from "./reconcilerSelection"; diff --git a/packages/rendering/dom/src/field-editor/reconcilerDeltaApply.ts b/packages/rendering/dom/src/field-editor/reconcilerDeltaApply.ts new file mode 100644 index 0000000..0982232 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/reconcilerDeltaApply.ts @@ -0,0 +1,138 @@ +import type { SchemaRegistry } from "@pen/types"; +import type { FieldEditorDelta } from "./crdt"; +import { + createInlineAtomElement, + getLogicalNodeLength, + isInlineAtomNode, +} from "./inlineAtomDom"; +import { createMarkedNode } from "./reconcilerMarks"; + +export function applyDeltaToDOM( + delta: readonly FieldEditorDelta[], + element: HTMLElement, + registry: SchemaRegistry, +): boolean { + let childIndex = 0; + let textOffset = 0; + + for (const entry of delta) { + if (entry.retain != null) { + let remaining = entry.retain; + while (remaining > 0 && childIndex < element.childNodes.length) { + const span = element.childNodes[childIndex]; + const available = getLogicalNodeLength(span) - textOffset; + + if (remaining < available) { + textOffset += remaining; + remaining = 0; + } else { + remaining -= available; + childIndex++; + textOffset = 0; + } + } + if (remaining > 0) return false; + + if (entry.attributes != null) { + return false; + } + } else if (typeof entry.insert === "string") { + const text = entry.insert; + + if (!entry.attributes) { + const span = element.childNodes[childIndex]; + if (span && span.nodeType === Node.TEXT_NODE) { + const existing = span.textContent ?? ""; + span.textContent = + existing.slice(0, textOffset) + + text + + existing.slice(textOffset); + textOffset += text.length; + } else if (span && span.nodeType === Node.ELEMENT_NODE) { + if (isInlineAtomNode(span)) { + if (textOffset !== 0) return false; + element.insertBefore( + document.createTextNode(text), + span, + ); + childIndex++; + textOffset = 0; + continue; + } + const leaf = deepLeafText(span); + if (!leaf) return false; + const existing = leaf.textContent ?? ""; + leaf.textContent = + existing.slice(0, textOffset) + + text + + existing.slice(textOffset); + textOffset += text.length; + } else { + element.appendChild(document.createTextNode(text)); + childIndex = element.childNodes.length - 1; + textOffset = text.length; + } + } else { + if (textOffset === 0) { + const node = createMarkedNode( + text, + entry.attributes, + registry, + ); + const ref = element.childNodes[childIndex] ?? null; + element.insertBefore(node, ref); + childIndex++; + } else { + return false; + } + } + } else if (entry.insert != null) { + return false; + } else if (entry.delete != null) { + let remaining = entry.delete; + while (remaining > 0 && childIndex < element.childNodes.length) { + const span = element.childNodes[childIndex]; + if (isInlineAtomNode(span)) { + if (textOffset !== 0) return false; + element.removeChild(span); + remaining -= 1; + continue; + } + const leaf = + span.nodeType === Node.TEXT_NODE + ? span + : deepLeafText(span); + if (!leaf) return false; + const existing = leaf.textContent ?? ""; + const available = getLogicalNodeLength(span) - textOffset; + + if (remaining < available) { + leaf.textContent = + existing.slice(0, textOffset) + + existing.slice(textOffset + remaining); + remaining = 0; + } else { + if (textOffset === 0) { + element.removeChild(span); + remaining -= existing.length; + } else { + leaf.textContent = existing.slice(0, textOffset); + remaining -= available; + childIndex++; + textOffset = 0; + } + } + } + } + } + return true; +} + +function deepLeafText(node: Node): Text | null { + if (node.nodeType === Node.TEXT_NODE) return node as Text; + for (let index = 0; index < node.childNodes.length; index++) { + const found = deepLeafText(node.childNodes[index]); + if (found) return found; + } + return null; +} diff --git a/packages/rendering/dom/src/field-editor/reconcilerFull.ts b/packages/rendering/dom/src/field-editor/reconcilerFull.ts new file mode 100644 index 0000000..fae3384 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/reconcilerFull.ts @@ -0,0 +1,77 @@ +import type { InlineDecoration, SchemaRegistry } from "@pen/types"; +import { sortDeltaAttributes } from "@pen/core"; +import type { FieldEditorDelta, FieldEditorTextLike } from "./crdt"; +import { restoreSelection, saveSelection } from "./reconcilerSelection"; +import { + applyInlineDecorationsToDeltas, + filterVisibleInlineDecorationDeltas, +} from "../utils/inlineDecorations"; +import { createInlineAtomElement } from "./inlineAtomDom"; +import { wrapWithMarks } from "./reconcilerMarks"; +import { patchDOM } from "./reconcilerPatch"; + +export function fullReconcileToDOM( + ytext: FieldEditorTextLike, + element: HTMLElement, + registry: SchemaRegistry, + options?: { + preserveSelection?: boolean; + inlineDecorations?: readonly InlineDecoration[]; + }, +): void { + const textDeltas = ytext.toDelta().filter( + ( + delta, + ): delta is FieldEditorDelta & { + insert: string | Record; + } => delta.insert != null, + ); + const renderedDeltas = + options?.inlineDecorations && options.inlineDecorations.length > 0 + ? filterVisibleInlineDecorationDeltas( + applyInlineDecorationsToDeltas( + textDeltas, + options.inlineDecorations, + ), + ) + : textDeltas; + fullReconcileDeltasToDOM(renderedDeltas, element, registry, options); +} + +export function fullReconcileDeltasToDOM( + deltas: FieldEditorDelta[], + element: HTMLElement, + registry: SchemaRegistry, + options?: { preserveSelection?: boolean }, +): void { + const orderedDeltas = deltas.map((delta) => { + if (!delta.attributes || Object.keys(delta.attributes).length < 2) { + return delta; + } + return { + ...delta, + attributes: sortDeltaAttributes(delta.attributes, registry), + }; + }); + + const preserveSelection = options?.preserveSelection ?? true; + const savedSelection = preserveSelection ? saveSelection(element) : null; + + const fragment = document.createDocumentFragment(); + for (const delta of orderedDeltas) { + if (delta.insert == null) continue; + let node: Node = + typeof delta.insert === "string" + ? document.createTextNode(delta.insert) + : createInlineAtomElement(delta.insert, registry); + if (delta.attributes) { + node = wrapWithMarks(node, delta.attributes, registry); + } + fragment.appendChild(node); + } + + patchDOM(element, fragment); + if (savedSelection) { + restoreSelection(element, savedSelection); + } +} diff --git a/packages/rendering/dom/src/field-editor/reconcilerMarks.ts b/packages/rendering/dom/src/field-editor/reconcilerMarks.ts new file mode 100644 index 0000000..76b9bab --- /dev/null +++ b/packages/rendering/dom/src/field-editor/reconcilerMarks.ts @@ -0,0 +1,150 @@ +import type { SchemaRegistry } from "@pen/types"; +import { INLINE_DECORATION_ATTRIBUTE_KEY } from "../utils/inlineDecorations"; + +export function wrapWithMarks( + node: Node, + attributes: Record, + registry: SchemaRegistry, +): Node { + let wrapped = node; + const decorationAttributes = isDecorationAttributesValue( + attributes[INLINE_DECORATION_ATTRIBUTE_KEY], + ) + ? attributes[INLINE_DECORATION_ATTRIBUTE_KEY] + : null; + + const entries = Object.entries(attributes) + .filter(([key]) => key !== INLINE_DECORATION_ATTRIBUTE_KEY) + .filter(([_, value]) => value !== null && value !== false) + .sort(([a], [b]) => { + const schemaA = registry.resolveInline(a); + const schemaB = registry.resolveInline(b); + return (schemaA?.priority ?? 0) - (schemaB?.priority ?? 0); + }); + + for (const [markType, markProps] of entries) { + const element = createMarkElement(markType, markProps); + element.appendChild(wrapped); + wrapped = element; + } + + if (decorationAttributes) { + const element = createMarkElement( + INLINE_DECORATION_ATTRIBUTE_KEY, + decorationAttributes, + ); + element.appendChild(wrapped); + wrapped = element; + } + + return wrapped; +} + +export function createMarkedNode( + text: string, + attributes: Record, + registry: SchemaRegistry, +): Node { + const node: Node = document.createTextNode(text); + return wrapWithMarks(node, attributes, registry); +} + +function createMarkElement(markType: string, props: unknown): HTMLElement { + switch (markType) { + case INLINE_DECORATION_ATTRIBUTE_KEY: { + const span = document.createElement("span"); + applyElementAttributes(span, props); + return span; + } + case "bold": + return document.createElement("strong"); + case "italic": + return document.createElement("em"); + case "underline": + return document.createElement("u"); + case "strikethrough": + return document.createElement("s"); + case "code": + return document.createElement("code"); + case "link": { + const anchor = document.createElement("a"); + if (typeof props === "object" && props !== null) { + const record = props as Record; + if (record.href) anchor.href = record.href as string; + if (record.title) anchor.title = record.title as string; + } + return anchor; + } + case "highlight": { + const mark = document.createElement("mark"); + if (typeof props === "object" && props !== null) { + const record = props as Record; + if (record.color) mark.style.backgroundColor = record.color as string; + } + return mark; + } + case "suggestion": { + const span = document.createElement("span"); + span.dataset.markType = markType; + + if (typeof props === "object" && props !== null) { + const record = props as Record; + const suggestionId = + typeof record.id === "string" && record.id.length > 0 + ? record.id + : null; + const suggestionAction = + record.action === "delete" ? "delete" : "insert"; + + if (suggestionId) { + span.dataset.suggestionId = suggestionId; + } + + span.dataset.suggestionAction = suggestionAction; + span.classList.add( + suggestionAction === "delete" + ? "pen-suggestion-delete" + : "pen-suggestion-insert", + ); + } + + return span; + } + default: { + const span = document.createElement("span"); + span.dataset.markType = markType; + return span; + } + } +} + +function applyElementAttributes(element: HTMLElement, props: unknown): void { + if (!isDecorationAttributesValue(props)) { + return; + } + + for (const [key, value] of Object.entries(props)) { + if (value === null || value === false || value === undefined) { + continue; + } + if (key === "class" && typeof value === "string") { + element.className = value; + continue; + } + if (key === "style" && typeof value === "string") { + element.style.cssText = value; + continue; + } + if (value === true) { + element.setAttribute(key, ""); + continue; + } + element.setAttribute(key, String(value)); + } +} + +function isDecorationAttributesValue( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/rendering/dom/src/field-editor/reconcilerPatch.ts b/packages/rendering/dom/src/field-editor/reconcilerPatch.ts new file mode 100644 index 0000000..3225bce --- /dev/null +++ b/packages/rendering/dom/src/field-editor/reconcilerPatch.ts @@ -0,0 +1,140 @@ +import { + areInlineAtomElementDataEqual, + copyInlineAtomElementData, + isInlineAtomChipNode, + isInlineAtomHostNode, + isInlineAtomNode, +} from "./inlineAtomDom"; + +export function patchDOM(target: HTMLElement, source: DocumentFragment): void { + const targetNodes = Array.from(target.childNodes); + const sourceNodes = Array.from(source.childNodes); + + let targetIndex = 0; + let sourceIndex = 0; + + while (sourceIndex < sourceNodes.length) { + const sourceNode = sourceNodes[sourceIndex]; + + if (targetIndex < targetNodes.length) { + const targetNode = targetNodes[targetIndex]; + + if (nodesStructurallyEqual(targetNode, sourceNode)) { + if ( + isInlineAtomHostNode(targetNode) && + isInlineAtomHostNode(sourceNode) + ) { + copyInlineAtomElementData(sourceNode, targetNode); + } else if ( + isInlineAtomNode(targetNode) && + isInlineAtomNode(sourceNode) + ) { + copyInlineAtomElementData(sourceNode, targetNode); + } + updateTextContent(targetNode, sourceNode); + targetIndex++; + sourceIndex++; + } else { + target.replaceChild(sourceNode, targetNode); + targetIndex++; + sourceIndex++; + } + } else { + target.appendChild(sourceNode); + sourceIndex++; + } + } + + while (target.childNodes.length > sourceNodes.length) { + target.removeChild(target.lastChild!); + } +} + +function nodesStructurallyEqual(a: Node, b: Node): boolean { + if (a.nodeType !== b.nodeType) return false; + if (a.nodeType === Node.TEXT_NODE) return true; + if (a.nodeType === Node.ELEMENT_NODE) { + const elementA = a as Element; + const elementB = b as Element; + if (isInlineAtomHostNode(elementA) || isInlineAtomHostNode(elementB)) { + if (!isInlineAtomHostNode(elementA) || !isInlineAtomHostNode(elementB)) { + return false; + } + if (!areInlineAtomElementDataEqual(elementA, elementB)) { + return false; + } + } else if (isInlineAtomNode(elementA) || isInlineAtomNode(elementB)) { + if (!isInlineAtomNode(elementA) || !isInlineAtomNode(elementB)) { + return false; + } + if (!areInlineAtomElementDataEqual(elementA, elementB)) { + return false; + } + } + if (elementA.tagName !== elementB.tagName) return false; + if (elementA.attributes.length !== elementB.attributes.length) return false; + for (let index = 0; index < elementA.attributes.length; index++) { + const attribute = elementA.attributes[index]; + if (elementB.getAttribute(attribute.name) !== attribute.value) { + return false; + } + } + if (elementA.childNodes.length !== elementB.childNodes.length) return false; + for (let index = 0; index < elementA.childNodes.length; index++) { + if ( + !nodesStructurallyEqual( + elementA.childNodes[index], + elementB.childNodes[index], + ) + ) { + return false; + } + } + return true; + } + return true; +} + +function updateTextContent(target: Node, source: Node): void { + if ( + target.nodeType === Node.TEXT_NODE && + source.nodeType === Node.TEXT_NODE + ) { + if (target.textContent !== source.textContent) { + target.textContent = source.textContent; + } + return; + } + if ( + target.nodeType === Node.ELEMENT_NODE && + source.nodeType === Node.ELEMENT_NODE + ) { + if (isInlineAtomHostNode(target) && isInlineAtomHostNode(source)) { + updateInlineAtomHostTextContent(target, source); + return; + } + for (let index = 0; index < target.childNodes.length; index++) { + updateTextContent(target.childNodes[index], source.childNodes[index]); + } + } +} + +function updateInlineAtomHostTextContent(target: Node, source: Node): void { + for (let index = 0; index < target.childNodes.length; index += 1) { + const targetChild = target.childNodes[index]; + const sourceChild = source.childNodes[index]; + if (!sourceChild) { + continue; + } + if ( + isInlineAtomChipNode(targetChild) && + isInlineAtomChipNode(sourceChild) + ) { + if (targetChild.textContent !== sourceChild.textContent) { + targetChild.textContent = sourceChild.textContent; + } + continue; + } + updateTextContent(targetChild, sourceChild); + } +} diff --git a/packages/rendering/dom/src/field-editor/reconcilerSelection.ts b/packages/rendering/dom/src/field-editor/reconcilerSelection.ts new file mode 100644 index 0000000..e85269a --- /dev/null +++ b/packages/rendering/dom/src/field-editor/reconcilerSelection.ts @@ -0,0 +1,67 @@ +import { + domPointToLogicalOffset, + findLogicalDOMPoint, +} from "./inlineAtomDom"; + +export interface SavedSelection { + anchorOffset: number; + focusOffset: number; +} + +export function saveSelection(element: HTMLElement): SavedSelection | null { + const sel = typeof window !== "undefined" ? window.getSelection() : null; + if (!sel || sel.rangeCount === 0) return null; + + const anchorOffset = computeCharacterOffset( + element, + sel.anchorNode, + sel.anchorOffset, + ); + const focusOffset = computeCharacterOffset( + element, + sel.focusNode, + sel.focusOffset, + ); + + return { anchorOffset, focusOffset }; +} + +export function restoreSelection( + element: HTMLElement, + saved: SavedSelection | null, +): void { + if (!saved) return; + try { + const sel = window.getSelection(); + if (!sel) return; + + const anchor = findPositionInDOM(element, saved.anchorOffset); + const focus = findPositionInDOM(element, saved.focusOffset); + if (!anchor || !focus) return; + + sel.setBaseAndExtent( + anchor.node, + anchor.offset, + focus.node, + focus.offset, + ); + } catch { + // Selection restoration can fail if DOM structure changed + } +} + +function computeCharacterOffset( + root: HTMLElement, + node: Node | null, + offset: number, +): number { + if (!node) return 0; + return domPointToLogicalOffset(root, node, offset); +} + +function findPositionInDOM( + root: HTMLElement, + charOffset: number, +): { node: Node; offset: number } | null { + return findLogicalDOMPoint(root, charOffset); +} diff --git a/packages/rendering/dom/src/field-editor/selectAllController.ts b/packages/rendering/dom/src/field-editor/selectAllController.ts new file mode 100644 index 0000000..4eebaf9 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectAllController.ts @@ -0,0 +1,69 @@ +import type { SelectionState } from "@pen/types"; +import { + resolveSelectAllBehavior, + type EditorSelectAllBehavior, +} from "../constants/selectAll"; + +export type SelectAllScope = "cell" | "block" | "document"; + +type SelectAllCycle = { + blockId: string; + scope: SelectAllScope; +}; + +export class SelectAllController { + private behavior: EditorSelectAllBehavior; + private cycle: SelectAllCycle | null = null; + private preserveCycle = false; + + constructor(behavior?: EditorSelectAllBehavior) { + this.behavior = behavior ?? resolveSelectAllBehavior("content-first"); + } + + getBehavior(): EditorSelectAllBehavior { + return this.behavior; + } + + setBehavior(behavior: EditorSelectAllBehavior): void { + if (this.behavior === behavior) { + return; + } + this.behavior = behavior; + this.resetCycle(); + } + + recordScope(blockId: string, scope: SelectAllScope): void { + this.preserveCycle = true; + this.cycle = { blockId, scope }; + } + + resetCycle(): void { + this.preserveCycle = false; + this.cycle = null; + } + + consumeShouldPreserveCycle( + selection: SelectionState | null, + matchesSelection: ( + cycle: SelectAllCycle, + selection: SelectionState | null, + ) => boolean, + ): boolean { + const preserve = + this.preserveCycle || + (this.cycle ? matchesSelection(this.cycle, selection) : false); + this.preserveCycle = false; + if (!preserve) { + this.cycle = null; + } + return preserve; + } + + hasScope(blockId: string | null | undefined, scope: SelectAllScope): boolean { + return ( + this.cycle != null && + this.cycle.blockId === blockId && + this.cycle.scope === scope + ); + } +} diff --git a/packages/rendering/dom/src/field-editor/selectionAuthority.ts b/packages/rendering/dom/src/field-editor/selectionAuthority.ts new file mode 100644 index 0000000..683eeb5 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionAuthority.ts @@ -0,0 +1,108 @@ +export type FieldEditorSelectionSource = + | "user-dom" + | "programmatic" + | "edit-context-textupdate" + | "history" + | "composition" + | "cell"; + +export type FieldEditorSelectionCell = { + row: number; + col: number; +}; + +export type FieldEditorSelectionSnapshot = { + blockId: string; + anchorOffset: number; + focusOffset: number; + cell?: FieldEditorSelectionCell; +}; + +const DEFAULT_PRECEDENCE: readonly FieldEditorSelectionSource[] = [ + "programmatic", + "edit-context-textupdate", + "composition", + "cell", + "user-dom", + "history", +]; + +export class FieldEditorSelectionAuthority { + private readonly selections = new Map< + FieldEditorSelectionSource, + FieldEditorSelectionSnapshot + >(); + private applyingSelectionDepth = 0; + + get isApplyingSelection(): number { + return this.applyingSelectionDepth; + } + + set( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + if (selection) { + this.selections.set(source, selection); + return; + } + this.selections.delete(source); + } + + get( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + const selection = this.selections.get(source) ?? null; + if (!selection || (blockId && selection.blockId !== blockId)) { + return null; + } + return selection; + } + + has(source: FieldEditorSelectionSource): boolean { + return this.selections.has(source); + } + + resolve( + blockId: string, + sources: readonly FieldEditorSelectionSource[] = DEFAULT_PRECEDENCE, + ): FieldEditorSelectionSnapshot | null { + for (const source of sources) { + const selection = this.get(source, blockId); + if (selection) { + return selection; + } + } + return null; + } + + clear(source: FieldEditorSelectionSource): void { + this.selections.delete(source); + } + + reset(): void { + this.selections.clear(); + this.applyingSelectionDepth = 0; + } + + beginApplyingSelection(): () => void { + this.applyingSelectionDepth += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + this.applyingSelectionDepth = Math.max( + 0, + this.applyingSelectionDepth - 1, + ); + }; + } + + applySelectionUntilNextFrame(): void { + const release = this.beginApplyingSelection(); + requestAnimationFrame(release); + } +} diff --git a/packages/rendering/dom/src/field-editor/selectionBridge.ts b/packages/rendering/dom/src/field-editor/selectionBridge.ts index d090e8d..581d3ab 100644 --- a/packages/rendering/dom/src/field-editor/selectionBridge.ts +++ b/packages/rendering/dom/src/field-editor/selectionBridge.ts @@ -8,86 +8,34 @@ import { getBlockSelectionRoleFromType, getSelectionLengthForRole, } from "../utils/blockSelectionSemantics"; - -/** - * Safely query a block element by ID, escaping special characters to prevent - * selector injection from untrusted CRDT data. - */ -export function queryBlockElement( - root: HTMLElement, - blockId: string, -): HTMLElement | null { - const escaped = - typeof CSS !== "undefined" && CSS.escape - ? CSS.escape(blockId) - : blockId.replace(/(["\]\\])/g, "\\$1"); - return root.querySelector( - `[${DATA_ATTRS.blockId}="${escaped}"]`, - ) as HTMLElement | null; -} - -/** - * Find the inline content element for a given block. - */ -export function queryInlineElement( - root: HTMLElement, - blockId: string, -): HTMLElement | null { - const blockEl = queryBlockElement(root, blockId); - return blockEl?.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; -} - -export type TextDiffOp = - | { type: "insert"; offset: number; text: string } - | { type: "delete"; offset: number; length: number }; - -/** - * O(n) scan from both ends to find the changed region. - * Returns delete + insert ops for the diff. - */ -export function computeTextDiff( - oldText: string, - newText: string, -): TextDiffOp[] { - if (oldText === newText) return []; - - let prefixLen = 0; - const minLen = Math.min(oldText.length, newText.length); - while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { - prefixLen++; - } - - let oldSuffix = oldText.length; - let newSuffix = newText.length; - while ( - oldSuffix > prefixLen && - newSuffix > prefixLen && - oldText[oldSuffix - 1] === newText[newSuffix - 1] - ) { - oldSuffix--; - newSuffix--; - } - - const ops: TextDiffOp[] = []; - - const deleteLen = oldSuffix - prefixLen; - if (deleteLen > 0) { - ops.push({ type: "delete", offset: prefixLen, length: deleteLen }); - } - - const insertText = newText.slice(prefixLen, newSuffix); - if (insertText.length > 0) { - ops.push({ type: "insert", offset: prefixLen, text: insertText }); - } - - return ops; -} - -export function extractTextFromDOM(element: HTMLElement): string { - return element.textContent ?? ""; -} +import { + domPointToLogicalOffset, + findLogicalDOMPoint, + getLogicalNodeLength, + isInlineAtomNode, +} from "./inlineAtomDom"; +import { + approximateInlineOffsetFromPoint, + getDistanceToRect, + getInlineCaretRectFromOffset, +} from "./selectionGeometry"; +import { + findBlockElement, + findInlineContentElement, + queryBlockElement, + queryInlineElement, +} from "./selectionDomQueries"; +export { + findBlockElement, + findInlineContentElement, + queryBlockElement, + queryInlineElement, +} from "./selectionDomQueries"; +export { + computeTextDiff, + extractTextFromDOM, + type TextDiffOp, +} from "./textDiff"; export interface SelectionPoint { blockId: string; @@ -117,44 +65,12 @@ interface ResolveSelectionPointOptions { previousPoint?: SelectionPoint | null; } -const WRAPPED_LINE_HYSTERESIS_PX = 6; -const WRAPPED_LINE_HORIZONTAL_SLACK_PX = 12; -const WRAPPED_LINE_DELTA_PX = 1; - function fallbackCharacterOffset( container: HTMLElement, targetNode: Node, targetOffset: number, ): number { - let charOffset = 0; - - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - ); - - let textNode: Text | null; - while ((textNode = walker.nextNode() as Text | null)) { - if (textNode === targetNode) { - return charOffset + Math.min(targetOffset, textNode.length); - } - charOffset += textNode.textContent?.length ?? 0; - } - - if (targetNode === container) { - let counted = 0; - for ( - let i = 0; - i < targetOffset && i < container.childNodes.length; - i++ - ) { - counted += container.childNodes[i].textContent?.length ?? 0; - } - return counted; - } - - return charOffset; + return domPointToLogicalOffset(container, targetNode, targetOffset); } /** @@ -171,269 +87,7 @@ export function domPointToOffset( return fallbackCharacterOffset(container, targetNode, targetOffset); } - try { - const range = container.ownerDocument.createRange(); - range.setStart(container, 0); - range.setEnd(targetNode, targetOffset); - return range.toString().length; - } catch { - return fallbackCharacterOffset(container, targetNode, targetOffset); - } -} - -/** - * Find the ancestor block element for a given DOM node. - */ -function findBlockElement(node: Node, root: HTMLElement): HTMLElement | null { - let current: Node | null = node; - while (current && current !== root) { - if ( - current instanceof HTMLElement && - current.hasAttribute(DATA_ATTRS.editorBlock) - ) { - return current; - } - current = current.parentNode; - } - return null; -} - -/** - * Find the inline content element inside a block. - */ -function findInlineContentElement(blockEl: HTMLElement): HTMLElement | null { - return blockEl.querySelector(`[${DATA_ATTRS.inlineContent}]`); -} - -function getDistanceToRect( - rect: DOMRect, - clientX: number, - clientY: number, -): { dx: number; dy: number } { - return { - dx: - clientX < rect.left - ? rect.left - clientX - : clientX > rect.right - ? clientX - rect.right - : 0, - dy: - clientY < rect.top - ? rect.top - clientY - : clientY > rect.bottom - ? clientY - rect.bottom - : 0, - }; -} - -function getCharacterRectAtOffset( - container: HTMLElement, - charOffset: number, -): DOMRect | null { - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - ); - let remaining = charOffset; - let textNode: Text | null; - - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining < len) { - const range = document.createRange(); - range.setStart(textNode, remaining); - range.setEnd(textNode, remaining + 1); - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter === "function") { - const rect = rangeRectGetter.call(range); - if (rect.width > 0 || rect.height > 0) { - return rect; - } - } - return null; - } - remaining -= len; - } - - return null; -} - -function getInlineCaretRectFromOffset( - inlineEl: HTMLElement, - offset: number, -): DOMRect { - const textLength = inlineEl.textContent?.length ?? 0; - const inlineRect = inlineEl.getBoundingClientRect(); - if (textLength <= 0) { - return { - x: inlineRect.left, - y: inlineRect.top, - left: inlineRect.left, - top: inlineRect.top, - right: inlineRect.left, - bottom: inlineRect.bottom, - width: 0, - height: inlineRect.height, - toJSON() { - return {}; - }, - } as DOMRect; - } - - if (offset <= 0) { - const firstRect = getCharacterRectAtOffset(inlineEl, 0); - const left = firstRect?.left ?? inlineRect.left; - const top = firstRect?.top ?? inlineRect.top; - const height = firstRect?.height ?? inlineRect.height; - return { - x: left, - y: top, - left, - top, - right: left, - bottom: top + height, - width: 0, - height, - toJSON() { - return {}; - }, - } as DOMRect; - } - - if (offset >= textLength) { - const lastRect = getCharacterRectAtOffset(inlineEl, textLength - 1); - const left = lastRect?.right ?? inlineRect.right; - const top = lastRect?.top ?? inlineRect.top; - const height = lastRect?.height ?? inlineRect.height; - return { - x: left, - y: top, - left, - top, - right: left, - bottom: top + height, - width: 0, - height, - toJSON() { - return {}; - }, - } as DOMRect; - } - - const previousRect = getCharacterRectAtOffset(inlineEl, offset - 1); - const nextRect = getCharacterRectAtOffset(inlineEl, offset); - const useNextRect = - previousRect && - nextRect && - nextRect.top > previousRect.top + 1; - const sourceRect = useNextRect - ? nextRect - : (previousRect ?? nextRect ?? inlineRect); - const left = useNextRect - ? (nextRect?.left ?? inlineRect.left) - : (previousRect?.right ?? - nextRect?.left ?? - inlineRect.left); - - return { - x: left, - y: sourceRect.top, - left, - top: sourceRect.top, - right: left, - bottom: sourceRect.top + sourceRect.height, - width: 0, - height: sourceRect.height, - toJSON() { - return {}; - }, - } as DOMRect; -} - -function getCaretDistanceMetrics( - rect: DOMRect, - clientX: number, - clientY: number, -): { - dx: number; - dy: number; -} { - return { - dx: Math.abs(clientX - rect.left), - dy: - clientY < rect.top - ? rect.top - clientY - : clientY > rect.bottom - ? clientY - rect.bottom - : 0, - }; -} - -function stabilizeWrappedLineOffset( - inlineEl: HTMLElement, - candidateOffset: number, - clientX: number, - clientY: number, - previousOffset: number | null | undefined, -): number { - if (previousOffset == null || previousOffset === candidateOffset) { - return candidateOffset; - } - - const previousRect = getInlineCaretRectFromOffset(inlineEl, previousOffset); - const candidateRect = getInlineCaretRectFromOffset(inlineEl, candidateOffset); - if (Math.abs(previousRect.top - candidateRect.top) <= WRAPPED_LINE_DELTA_PX) { - return candidateOffset; - } - - const previousMetrics = getCaretDistanceMetrics(previousRect, clientX, clientY); - const candidateMetrics = getCaretDistanceMetrics(candidateRect, clientX, clientY); - const isNearWrappedBoundary = - previousMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX && - candidateMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX; - if (!isNearWrappedBoundary) { - return candidateOffset; - } - - const shouldPreservePreviousLine = - previousMetrics.dx <= - candidateMetrics.dx + WRAPPED_LINE_HORIZONTAL_SLACK_PX && - previousMetrics.dy <= candidateMetrics.dy + WRAPPED_LINE_DELTA_PX; - return shouldPreservePreviousLine ? previousOffset : candidateOffset; -} - -function approximateInlineOffsetFromPoint( - inlineEl: HTMLElement, - clientX: number, - clientY: number, - previousOffset?: number | null, -): number { - const textLength = inlineEl.textContent?.length ?? 0; - if (textLength <= 0) return 0; - - let bestOffset = 0; - let bestScore = Number.POSITIVE_INFINITY; - - for (let offset = 0; offset <= textLength; offset++) { - const rect = getInlineCaretRectFromOffset(inlineEl, offset); - const { dx, dy } = getCaretDistanceMetrics(rect, clientX, clientY); - const score = dy * 1000 + dx; - if (score < bestScore) { - bestScore = score; - bestOffset = offset; - } - } - - return stabilizeWrappedLineOffset( - inlineEl, - bestOffset, - clientX, - clientY, - previousOffset, - ); + return domPointToLogicalOffset(container, targetNode, targetOffset); } function getBlockSurfaceRole( @@ -452,7 +106,7 @@ function getBlockSurfaceRole( function getBlockTextLength(blockEl: HTMLElement): number { const inlineEl = findInlineContentElement(blockEl); if (inlineEl) { - return inlineEl.textContent?.length ?? 0; + return getLogicalNodeLength(inlineEl); } return blockEl.textContent?.length ?? 0; } @@ -522,9 +176,7 @@ export function getClosestBlockElementFromPoint( return hitBlockEl; } - const blockElements = root.querySelectorAll( - `[${DATA_ATTRS.editorBlock}]`, - ); + const blockElements = root.querySelectorAll(`[${DATA_ATTRS.editorBlock}]`); let closestBlockEl: HTMLElement | null = null; let bestScore = Number.POSITIVE_INFINITY; @@ -634,6 +286,8 @@ export function pointToEditorSelectionPoint( ): SelectionPoint | null { const doc = root.ownerDocument; if (!doc) return null; + const atomPoint = resolveInlineAtomPoint(root, clientX, clientY, options); + if (atomPoint) return atomPoint; const caretFromPoint = doc as Document & { caretPositionFromPoint?: ( x: number, @@ -644,6 +298,16 @@ export function pointToEditorSelectionPoint( const position = caretFromPoint.caretPositionFromPoint?.(clientX, clientY); if (position) { + const inlineBoundaryPoint = resolveInlineContainerBoundaryPoint( + root, + position.offsetNode, + position.offset, + clientX, + clientY, + options, + ); + if (inlineBoundaryPoint) return inlineBoundaryPoint; + const resolved = resolveSelectionPoint( root, position.offsetNode, @@ -655,6 +319,16 @@ export function pointToEditorSelectionPoint( const range = caretFromPoint.caretRangeFromPoint?.(clientX, clientY); if (range) { + const inlineBoundaryPoint = resolveInlineContainerBoundaryPoint( + root, + range.startContainer, + range.startOffset, + clientX, + clientY, + options, + ); + if (inlineBoundaryPoint) return inlineBoundaryPoint; + const resolved = resolveSelectionPoint( root, range.startContainer, @@ -664,7 +338,11 @@ export function pointToEditorSelectionPoint( if (resolved) return resolved; } - const hoveredBlockEl = getClosestBlockElementFromPoint(root, clientX, clientY); + const hoveredBlockEl = getClosestBlockElementFromPoint( + root, + clientX, + clientY, + ); if (!hoveredBlockEl) return null; return getSelectionPointForBlockAtPointer( hoveredBlockEl, @@ -674,318 +352,117 @@ export function pointToEditorSelectionPoint( ); } -/** - * Convert DOM selection range to editor (blockId, offset) pairs. - */ -export function domSelectionToEditor( +function resolveInlineAtomPoint( root: HTMLElement, -): { anchor: SelectionPoint; focus: SelectionPoint } | null { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0) return null; - - const anchorNode = sel.anchorNode; - const focusNode = sel.focusNode; - if (!anchorNode || !focusNode) return null; - if (!root.contains(anchorNode) || !root.contains(focusNode)) return null; - - const anchor = resolveSelectionPoint(root, anchorNode, sel.anchorOffset); - const focus = resolveSelectionPoint(root, focusNode, sel.focusOffset); - if (!anchor || !focus) return null; - - return { anchor, focus }; -} - -/** - * Set DOM selection from editor (blockId, offset) pairs. - */ -export function editorSelectionToDOM( - root: HTMLElement, - anchor: SelectionPoint, - focus: SelectionPoint, -): void { - const anchorResult = findDOMPoint(root, anchor.blockId, anchor.offset); - const focusResult = findDOMPoint(root, focus.blockId, focus.offset); - if (!anchorResult || !focusResult) return; - - const sel = window.getSelection(); - if (!sel) return; - - setDOMSelection(sel, anchorResult, focusResult); -} - -export function getSelectionPointRect( - root: HTMLElement, - point: SelectionPoint, -): DOMRect | null { - const domPoint = findDOMPoint(root, point.blockId, point.offset); - if (!domPoint) return null; - - const blockEl = queryBlockElement(root, point.blockId); - const inlineEl = blockEl?.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; - if (!inlineEl) return null; - - const doc = root.ownerDocument; - if (!doc) return null; - - const range = doc.createRange(); - range.setStart(domPoint.node, domPoint.offset); - range.collapse(true); - - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter === "function") { - const rect = rangeRectGetter.call(range); - if (rect.height > 0 || rect.width > 0) { - return rect; - } - } - - return getInlineCaretRectFromOffset(inlineEl, point.offset); -} - -export function getTextSelectionClientRects( - root: HTMLElement, - selection: { - anchor: SelectionPoint; - focus: SelectionPoint; - }, -): DOMRect[] { - const doc = root.ownerDocument; - if (!doc) { - return []; - } - - const anchorPoint = findDOMPoint( - root, - selection.anchor.blockId, - selection.anchor.offset, - ); - const focusPoint = findDOMPoint( - root, - selection.focus.blockId, - selection.focus.offset, - ); - if (!anchorPoint || !focusPoint) { - return []; - } - - const range = doc.createRange(); - try { - range.setStart(anchorPoint.node, anchorPoint.offset); - range.setEnd(focusPoint.node, focusPoint.offset); - } catch { - range.setStart(focusPoint.node, focusPoint.offset); - range.setEnd(anchorPoint.node, anchorPoint.offset); + clientX: number, + clientY: number, + options: ResolveSelectionPointOptions, +): SelectionPoint | null { + const hitElement = + typeof root.ownerDocument.elementFromPoint === "function" + ? root.ownerDocument.elementFromPoint(clientX, clientY) + : null; + if (!hitElement || !root.contains(hitElement)) { + return null; } - const rangeClientRectGetter = ( - range as Range & { getClientRects?: () => DOMRectList | DOMRect[] } - ).getClientRects; - const clientRects = - typeof rangeClientRectGetter === "function" - ? Array.from(rangeClientRectGetter.call(range)) - : []; - if (clientRects.length > 0) { - return clientRects.filter((rect) => rect.width > 0 || rect.height > 0); + const atomElement = findInlineAtomElement(hitElement, root); + if (!atomElement) { + return null; } - const rangeRectGetter = ( - range as Range & { getBoundingClientRect?: () => DOMRect } - ).getBoundingClientRect; - if (typeof rangeRectGetter !== "function") { - return []; + const blockEl = findBlockElement(atomElement, root); + if (!blockEl || getBlockSurfaceRole(blockEl) !== "editable-inline") { + return null; } - const boundingRect = rangeRectGetter.call(range); - return boundingRect.width > 0 || boundingRect.height > 0 - ? [boundingRect] - : []; + return getSelectionPointForBlockAtPointer( + blockEl, + clientX, + clientY, + options, + ); } -/** - * Find the DOM text node and offset for a given (blockId, characterOffset). - */ -function findDOMPoint( +function findInlineAtomElement( + element: Element, root: HTMLElement, - blockId: string, - charOffset: number, -): { node: Node; offset: number } | null { - const blockEl = queryBlockElement(root, blockId); - if (!blockEl) return null; - - const inlineEl = blockEl.querySelector( - `[${DATA_ATTRS.inlineContent}]`, - ) as HTMLElement | null; - if (!inlineEl) return null; - - const walker = document.createTreeWalker( - inlineEl, - NodeFilter.SHOW_TEXT, - null, - ); - - let remaining = charOffset; - let textNode: Text | null; - while ((textNode = walker.nextNode() as Text | null)) { - const len = textNode.textContent?.length ?? 0; - if (remaining <= len) { - return { node: textNode, offset: remaining }; +): HTMLElement | null { + let current: Element | null = element; + while (current && current !== root) { + if (isInlineAtomNode(current)) { + return current; } - remaining -= len; + current = current.parentElement; } - - // Past end — position at end of the last text node. Using the last child - // element here is incorrect because element offsets are child indices, not - // character positions. - const lastText = getLastTextNode(inlineEl); - if (lastText) { - return { - node: lastText, - offset: lastText.textContent?.length ?? 0, - }; - } - return { node: inlineEl, offset: 0 }; + return null; } -function getLastTextNode(root: HTMLElement): Text | null { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); - let lastText: Text | null = null; - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - lastText = current; +function resolveInlineContainerBoundaryPoint( + root: HTMLElement, + node: Node, + offset: number, + clientX: number, + clientY: number, + options: ResolveSelectionPointOptions, +): SelectionPoint | null { + const blockEl = findBlockElement(node, root); + if (!blockEl || getBlockSurfaceRole(blockEl) !== "editable-inline") { + return null; } - return lastText; -} -/** - * Get the current selection as character offsets within the active inline content. - * Used by DIRECT_HANDLERS to know the selection range for editing operations. - */ -export function getDirectionalSelectionOffsets( - inlineElement: HTMLElement, -): DirectionalSelectionOffsets | null { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0) return null; - if (!sel.anchorNode || !sel.focusNode) return null; - if ( - !isNodeWithinOrEqual(inlineElement, sel.anchorNode) || - !isNodeWithinOrEqual(inlineElement, sel.focusNode) - ) { + const inlineEl = findInlineContentElement(blockEl); + if (!inlineEl || !isInlineBoundaryFallbackPoint(inlineEl, node, offset)) { return null; } - const anchor = domPointToOffset( - inlineElement, - sel.anchorNode, - sel.anchorOffset, - ); - const focus = domPointToOffset( - inlineElement, - sel.focusNode, - sel.focusOffset, + const geometricPoint = getSelectionPointForBlockAtPointer( + blockEl, + clientX, + clientY, + options, ); - - return { - anchor, - focus, - start: Math.min(anchor, focus), - end: Math.max(anchor, focus), - }; + return geometricPoint && geometricPoint.offset > 0 ? geometricPoint : null; } -export function getSelectionOffsets( - inlineElement: HTMLElement, -): { start: number; end: number } | null { - const offsets = getDirectionalSelectionOffsets(inlineElement); - if (!offsets) return null; +function isInlineBoundaryFallbackPoint( + inlineEl: HTMLElement, + node: Node, + offset: number, +): boolean { + if (node === inlineEl) { + return offset === 0; + } - return { start: offsets.start, end: offsets.end }; + return node instanceof HTMLElement && node.contains(inlineEl); } /** - * Get the caret offset (collapsed cursor position) within an inline element. + * Convert DOM selection range to editor (blockId, offset) pairs. */ -export function getCaretOffset(inlineElement: HTMLElement): number { - const offsets = getSelectionOffsets(inlineElement); - return offsets?.start ?? 0; -} - -function setDOMSelection( - selection: Selection, - anchor: { node: Node; offset: number }, - focus: { node: Node; offset: number }, -): void { - selection.removeAllRanges(); - - const setBaseAndExtent = ( - selection as Selection & { - setBaseAndExtent?: ( - anchorNode: Node, - anchorOffset: number, - focusNode: Node, - focusOffset: number, - ) => void; - } - ).setBaseAndExtent; - if (typeof setBaseAndExtent === "function") { - try { - setBaseAndExtent.call( - selection, - anchor.node, - anchor.offset, - focus.node, - focus.offset, - ); - return; - } catch { - // Fall back to the range-based path in test environments like jsdom. - } - } - - const collapseRange = document.createRange(); - collapseRange.setStart(anchor.node, anchor.offset); - collapseRange.collapse(true); - selection.addRange(collapseRange); - - if ( - (anchor.node !== focus.node || anchor.offset !== focus.offset) && - typeof selection.extend === "function" - ) { - selection.extend(focus.node, focus.offset); - return; - } - - selection.removeAllRanges(); - const orderedRange = document.createRange(); - if (compareDOMPoints(anchor, focus) <= 0) { - orderedRange.setStart(anchor.node, anchor.offset); - orderedRange.setEnd(focus.node, focus.offset); - } else { - orderedRange.setStart(focus.node, focus.offset); - orderedRange.setEnd(anchor.node, anchor.offset); - } - selection.addRange(orderedRange); -} - -function compareDOMPoints( - left: { node: Node; offset: number }, - right: { node: Node; offset: number }, -): number { - if (left.node === right.node) { - return left.offset - right.offset; - } +export function domSelectionToEditor( + root: HTMLElement, +): { anchor: SelectionPoint; focus: SelectionPoint } | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return null; - const leftRange = document.createRange(); - leftRange.setStart(left.node, left.offset); - leftRange.collapse(true); + const anchorNode = sel.anchorNode; + const focusNode = sel.focusNode; + if (!anchorNode || !focusNode) return null; + if (!root.contains(anchorNode) || !root.contains(focusNode)) return null; - const rightRange = document.createRange(); - rightRange.setStart(right.node, right.offset); - rightRange.collapse(true); + const anchor = resolveSelectionPoint(root, anchorNode, sel.anchorOffset); + const focus = resolveSelectionPoint(root, focusNode, sel.focusOffset); + if (!anchor || !focus) return null; - return leftRange.compareBoundaryPoints(Range.START_TO_START, rightRange); + return { anchor, focus }; } +export { + editorSelectionToDOM, + getCaretOffset, + getDirectionalSelectionOffsets, + getSelectionOffsets, + getSelectionPointRect, + getTextSelectionClientRects, +} from "./selectionBridgeOffsets"; diff --git a/packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts b/packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts new file mode 100644 index 0000000..8d969c6 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionBridgeOffsets.ts @@ -0,0 +1,270 @@ +import { DATA_ATTRS } from "../utils/dataAttributes"; +import { + findLogicalDOMPoint, + getInlineAtomPointerOffset, + getLogicalNodeLength, +} from "./inlineAtomDom"; +import { getInlineCaretRectFromOffset } from "./selectionGeometry"; +import { queryBlockElement } from "./selectionDomQueries"; +import { domPointToOffset, type DirectionalSelectionOffsets, type SelectionPoint } from "./selectionBridge"; +function isNodeWithinOrEqual(container: HTMLElement, node: Node): boolean { + return node === container || container.contains(node); +} + +/** + * Set DOM selection from editor (blockId, offset) pairs. + */ +export function editorSelectionToDOM( + root: HTMLElement, + anchor: SelectionPoint, + focus: SelectionPoint, +): void { + const anchorResult = findDOMPoint(root, anchor.blockId, anchor.offset); + const focusResult = findDOMPoint(root, focus.blockId, focus.offset); + if (!anchorResult || !focusResult) return; + + const sel = window.getSelection(); + if (!sel) return; + + setDOMSelection(sel, anchorResult, focusResult); +} + +export function getSelectionPointRect( + root: HTMLElement, + point: SelectionPoint, +): DOMRect | null { + const domPoint = findDOMPoint(root, point.blockId, point.offset); + if (!domPoint) return null; + + const blockEl = queryBlockElement(root, point.blockId); + const inlineEl = blockEl?.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + if (!inlineEl) return null; + + const doc = root.ownerDocument; + if (!doc) return null; + + const range = doc.createRange(); + range.setStart(domPoint.node, domPoint.offset); + range.collapse(true); + + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter === "function") { + const rect = rangeRectGetter.call(range); + if (rect.height > 0 || rect.width > 0) { + return rect; + } + } + + return getInlineCaretRectFromOffset(inlineEl, point.offset); +} + +export function getTextSelectionClientRects( + root: HTMLElement, + selection: { + anchor: SelectionPoint; + focus: SelectionPoint; + }, +): DOMRect[] { + const doc = root.ownerDocument; + if (!doc) { + return []; + } + + const anchorPoint = findDOMPoint( + root, + selection.anchor.blockId, + selection.anchor.offset, + ); + const focusPoint = findDOMPoint( + root, + selection.focus.blockId, + selection.focus.offset, + ); + if (!anchorPoint || !focusPoint) { + return []; + } + + const range = doc.createRange(); + try { + range.setStart(anchorPoint.node, anchorPoint.offset); + range.setEnd(focusPoint.node, focusPoint.offset); + } catch { + range.setStart(focusPoint.node, focusPoint.offset); + range.setEnd(anchorPoint.node, anchorPoint.offset); + } + + const rangeClientRectGetter = ( + range as Range & { getClientRects?: () => DOMRectList | DOMRect[] } + ).getClientRects; + const clientRects = + typeof rangeClientRectGetter === "function" + ? Array.from(rangeClientRectGetter.call(range)) + : []; + if (clientRects.length > 0) { + return clientRects.filter((rect) => rect.width > 0 || rect.height > 0); + } + + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter !== "function") { + return []; + } + + const boundingRect = rangeRectGetter.call(range); + return boundingRect.width > 0 || boundingRect.height > 0 + ? [boundingRect] + : []; +} + +/** + * Find the DOM text node and offset for a given (blockId, characterOffset). + */ +function findDOMPoint( + root: HTMLElement, + blockId: string, + charOffset: number, +): { node: Node; offset: number } | null { + const blockEl = queryBlockElement(root, blockId); + if (!blockEl) return null; + + const inlineEl = blockEl.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; + if (!inlineEl) return null; + + return findLogicalDOMPoint(inlineEl, charOffset); +} + +/** + * Get the current selection as character offsets within the active inline content. + * Used by DIRECT_HANDLERS to know the selection range for editing operations. + */ +export function getDirectionalSelectionOffsets( + inlineElement: HTMLElement, +): DirectionalSelectionOffsets | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return null; + if (!sel.anchorNode || !sel.focusNode) return null; + if ( + !isNodeWithinOrEqual(inlineElement, sel.anchorNode) || + !isNodeWithinOrEqual(inlineElement, sel.focusNode) + ) { + return null; + } + + const anchor = domPointToOffset( + inlineElement, + sel.anchorNode, + sel.anchorOffset, + ); + const focus = domPointToOffset( + inlineElement, + sel.focusNode, + sel.focusOffset, + ); + + return { + anchor, + focus, + start: Math.min(anchor, focus), + end: Math.max(anchor, focus), + }; +} + +export function getSelectionOffsets( + inlineElement: HTMLElement, +): { start: number; end: number } | null { + const offsets = getDirectionalSelectionOffsets(inlineElement); + if (!offsets) return null; + + return { start: offsets.start, end: offsets.end }; +} + +/** + * Get the caret offset (collapsed cursor position) within an inline element. + */ +export function getCaretOffset(inlineElement: HTMLElement): number { + const offsets = getSelectionOffsets(inlineElement); + return offsets?.start ?? 0; +} + +function setDOMSelection( + selection: Selection, + anchor: { node: Node; offset: number }, + focus: { node: Node; offset: number }, +): void { + selection.removeAllRanges(); + + const setBaseAndExtent = ( + selection as Selection & { + setBaseAndExtent?: ( + anchorNode: Node, + anchorOffset: number, + focusNode: Node, + focusOffset: number, + ) => void; + } + ).setBaseAndExtent; + if (typeof setBaseAndExtent === "function") { + try { + setBaseAndExtent.call( + selection, + anchor.node, + anchor.offset, + focus.node, + focus.offset, + ); + return; + } catch { + // Fall back to the range-based path in test environments like jsdom. + } + } + + const collapseRange = document.createRange(); + collapseRange.setStart(anchor.node, anchor.offset); + collapseRange.collapse(true); + selection.addRange(collapseRange); + + if ( + (anchor.node !== focus.node || anchor.offset !== focus.offset) && + typeof selection.extend === "function" + ) { + selection.extend(focus.node, focus.offset); + return; + } + + selection.removeAllRanges(); + const orderedRange = document.createRange(); + if (compareDOMPoints(anchor, focus) <= 0) { + orderedRange.setStart(anchor.node, anchor.offset); + orderedRange.setEnd(focus.node, focus.offset); + } else { + orderedRange.setStart(focus.node, focus.offset); + orderedRange.setEnd(anchor.node, anchor.offset); + } + selection.addRange(orderedRange); +} + +function compareDOMPoints( + left: { node: Node; offset: number }, + right: { node: Node; offset: number }, +): number { + if (left.node === right.node) { + return left.offset - right.offset; + } + + const leftRange = document.createRange(); + leftRange.setStart(left.node, left.offset); + leftRange.collapse(true); + + const rightRange = document.createRange(); + rightRange.setStart(right.node, right.offset); + rightRange.collapse(true); + + return leftRange.compareBoundaryPoints(Range.START_TO_START, rightRange); +} diff --git a/packages/rendering/dom/src/field-editor/selectionCoordinator.ts b/packages/rendering/dom/src/field-editor/selectionCoordinator.ts new file mode 100644 index 0000000..8fd3ab3 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionCoordinator.ts @@ -0,0 +1,203 @@ +import type { SelectionState } from "@pen/types"; +import type { PenFieldEditorFocusOptions } from "./controller"; +import { + FieldEditorSelectionAuthority, + type FieldEditorSelectionSnapshot, + type FieldEditorSelectionSource, +} from "./selectionAuthority"; +import { SelectionProjectionController } from "./selectionProjectionController"; + +type SelectionProjectionControllerOptions = ConstructorParameters< + typeof SelectionProjectionController +>[0]; + +export class FieldEditorSelectionCoordinator { + private readonly _authority = new FieldEditorSelectionAuthority(); + private readonly _projection: SelectionProjectionController; + private _editContextSelection: FieldEditorSelectionSnapshot | null = null; + + constructor(options: SelectionProjectionControllerOptions) { + this._projection = new SelectionProjectionController(options); + } + + get isApplyingSelection(): number { + return this._authority.isApplyingSelection; + } + + reset(): void { + this._authority.reset(); + this._editContextSelection = null; + this._projection.reset(); + } + + resetAuthority(): void { + this._authority.reset(); + this._editContextSelection = null; + } + + setAuthoritySelection( + source: FieldEditorSelectionSource, + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._authority.set(source, selection); + } + + getAuthoritySelection( + source: FieldEditorSelectionSource, + blockId?: string | null, + ): FieldEditorSelectionSnapshot | null { + return this._authority.get(source, blockId); + } + + hasAuthoritySelection(source: FieldEditorSelectionSource): boolean { + return this._authority.has(source); + } + + clearAuthoritySelection(source: FieldEditorSelectionSource): void { + this._authority.clear(source); + } + + beginApplyingSelection(): () => void { + return this._authority.beginApplyingSelection(); + } + + applySelectionUntilNextFrame(): void { + this._authority.applySelectionUntilNextFrame(); + } + + setEditContextSelection( + selection: FieldEditorSelectionSnapshot | null, + ): void { + this._editContextSelection = selection; + } + + getEditContextSelection(blockId?: string | null): FieldEditorSelectionSnapshot | null { + if ( + !this._editContextSelection || + (blockId && this._editContextSelection.blockId !== blockId) + ) { + return null; + } + return this._editContextSelection; + } + + beginPointerSelection(): void { + this._projection.beginPointerSelection(); + } + + endPointerSelection(): void { + this._projection.endPointerSelection(); + } + + consumeDomSelectionProjectionSuppression(): boolean { + return this._projection.consumeDomSelectionProjectionSuppression(); + } + + suppressNextDomSelectionProjection(): void { + this._projection.suppressNextDomSelectionProjection(); + } + + shouldHandleDomSelectionChange( + blockId: string | null, + isApplyingSelection: number, + ): boolean { + return this._projection.shouldHandleDomSelectionChange( + blockId, + isApplyingSelection, + ); + } + + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + return this._projection.resolveProgrammaticInputRange( + blockId, + liveRange, + ); + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + return this._projection.shouldIgnoreDomTextSelection(anchor, focus); + } + + isProgrammaticDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + return this._projection.isProgrammaticDomTextSelection(anchor, focus); + } + + prepareSyncedTextSelection( + currentSelection: SelectionState | null, + blockId: string, + anchorOffset: number, + focusOffset: number, + ): "skip" | "apply" { + return this._projection.prepareSyncedTextSelection( + currentSelection, + blockId, + anchorOffset, + focusOffset, + ); + } + + notifyTextSelectionSet( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + this._projection.notifyTextSelectionSet( + blockId, + anchorOffset, + focusOffset, + ); + } + + activateTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._projection.activateTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._projection.commitProgrammaticTextSelection( + blockId, + anchorOffset, + focusOffset, + options, + ); + } + + syncDomSelectionOnce(): void { + this._projection.syncDomSelectionOnce(); + } + + shouldProjectSelectionAfterReconcile(): boolean { + return this._projection.shouldProjectSelectionAfterReconcile(); + } + + recordUserSelectionIntent(): void { + this._projection.recordUserSelectionIntent(); + } + + shouldSuppressSelectionSync(): boolean { + return this._projection.shouldSuppressSelectionSync(); + } +} diff --git a/packages/rendering/dom/src/field-editor/selectionDomQueries.ts b/packages/rendering/dom/src/field-editor/selectionDomQueries.ts new file mode 100644 index 0000000..5b9661f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionDomQueries.ts @@ -0,0 +1,60 @@ +import { DATA_ATTRS } from "../utils/dataAttributes"; + +/** + * Safely query a block element by ID, escaping special characters to prevent + * selector injection from untrusted CRDT data. + */ +export function queryBlockElement( + root: HTMLElement, + blockId: string, +): HTMLElement | null { + const escaped = + typeof CSS !== "undefined" && CSS.escape + ? CSS.escape(blockId) + : blockId.replace(/(["\]\\])/g, "\\$1"); + return root.querySelector( + `[${DATA_ATTRS.blockId}="${escaped}"]`, + ) as HTMLElement | null; +} + +/** + * Find the inline content element for a given block. + */ +export function queryInlineElement( + root: HTMLElement, + blockId: string, +): HTMLElement | null { + const blockEl = queryBlockElement(root, blockId); + return blockEl?.querySelector( + `[${DATA_ATTRS.inlineContent}]`, + ) as HTMLElement | null; +} + +/** + * Find the ancestor block element for a given DOM node. + */ +export function findBlockElement( + node: Node, + root: HTMLElement, +): HTMLElement | null { + let current: Node | null = node; + while (current && current !== root) { + if ( + current instanceof HTMLElement && + current.hasAttribute(DATA_ATTRS.editorBlock) + ) { + return current; + } + current = current.parentNode; + } + return null; +} + +/** + * Find the inline content element inside a block. + */ +export function findInlineContentElement( + blockEl: HTMLElement, +): HTMLElement | null { + return blockEl.querySelector(`[${DATA_ATTRS.inlineContent}]`); +} diff --git a/packages/rendering/dom/src/field-editor/selectionGeometry.ts b/packages/rendering/dom/src/field-editor/selectionGeometry.ts new file mode 100644 index 0000000..533c1b8 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionGeometry.ts @@ -0,0 +1,247 @@ +import { + findLogicalDOMPoint, + getInlineAtomPointerOffset, + getLogicalNodeLength, +} from "./inlineAtomDom"; + +const WRAPPED_LINE_HYSTERESIS_PX = 6; +const WRAPPED_LINE_HORIZONTAL_SLACK_PX = 12; +const WRAPPED_LINE_DELTA_PX = 1; + +export function getDistanceToRect( + rect: DOMRect, + clientX: number, + clientY: number, +): { dx: number; dy: number } { + return { + dx: + clientX < rect.left + ? rect.left - clientX + : clientX > rect.right + ? clientX - rect.right + : 0, + dy: + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0, + }; +} + +function getCharacterRectAtOffset( + container: HTMLElement, + charOffset: number, +): DOMRect | null { + const domPoint = findLogicalDOMPoint(container, charOffset); + const range = document.createRange(); + try { + range.setStart(domPoint.node, domPoint.offset); + range.setEnd(domPoint.node, domPoint.offset); + } catch { + return null; + } + const rangeRectGetter = ( + range as Range & { getBoundingClientRect?: () => DOMRect } + ).getBoundingClientRect; + if (typeof rangeRectGetter === "function") { + const rect = rangeRectGetter.call(range); + if (rect.width > 0 || rect.height > 0) { + return rect; + } + } + + return null; +} + +export function getInlineCaretRectFromOffset( + inlineEl: HTMLElement, + offset: number, +): DOMRect { + const textLength = getLogicalNodeLength(inlineEl); + const inlineRect = inlineEl.getBoundingClientRect(); + if (textLength <= 0) { + return { + x: inlineRect.left, + y: inlineRect.top, + left: inlineRect.left, + top: inlineRect.top, + right: inlineRect.left, + bottom: inlineRect.bottom, + width: 0, + height: inlineRect.height, + toJSON() { + return {}; + }, + } as DOMRect; + } + + if (offset <= 0) { + const firstRect = getCharacterRectAtOffset(inlineEl, 0); + const left = firstRect?.left ?? inlineRect.left; + const top = firstRect?.top ?? inlineRect.top; + const height = firstRect?.height ?? inlineRect.height; + return { + x: left, + y: top, + left, + top, + right: left, + bottom: top + height, + width: 0, + height, + toJSON() { + return {}; + }, + } as DOMRect; + } + + if (offset >= textLength) { + const lastRect = getCharacterRectAtOffset(inlineEl, textLength - 1); + const left = lastRect?.right ?? inlineRect.right; + const top = lastRect?.top ?? inlineRect.top; + const height = lastRect?.height ?? inlineRect.height; + return { + x: left, + y: top, + left, + top, + right: left, + bottom: top + height, + width: 0, + height, + toJSON() { + return {}; + }, + } as DOMRect; + } + + const previousRect = getCharacterRectAtOffset(inlineEl, offset - 1); + const nextRect = getCharacterRectAtOffset(inlineEl, offset); + const useNextRect = + previousRect && nextRect && nextRect.top > previousRect.top + 1; + const sourceRect = useNextRect + ? nextRect + : (previousRect ?? nextRect ?? inlineRect); + const left = useNextRect + ? (nextRect?.left ?? inlineRect.left) + : (previousRect?.right ?? nextRect?.left ?? inlineRect.left); + + return { + x: left, + y: sourceRect.top, + left, + top: sourceRect.top, + right: left, + bottom: sourceRect.top + sourceRect.height, + width: 0, + height: sourceRect.height, + toJSON() { + return {}; + }, + } as DOMRect; +} + +function getCaretDistanceMetrics( + rect: DOMRect, + clientX: number, + clientY: number, +): { + dx: number; + dy: number; +} { + return { + dx: Math.abs(clientX - rect.left), + dy: + clientY < rect.top + ? rect.top - clientY + : clientY > rect.bottom + ? clientY - rect.bottom + : 0, + }; +} + +function stabilizeWrappedLineOffset( + inlineEl: HTMLElement, + candidateOffset: number, + clientX: number, + clientY: number, + previousOffset: number | null | undefined, +): number { + if (previousOffset == null || previousOffset === candidateOffset) { + return candidateOffset; + } + + const previousRect = getInlineCaretRectFromOffset(inlineEl, previousOffset); + const candidateRect = getInlineCaretRectFromOffset( + inlineEl, + candidateOffset, + ); + if ( + Math.abs(previousRect.top - candidateRect.top) <= WRAPPED_LINE_DELTA_PX + ) { + return candidateOffset; + } + + const previousMetrics = getCaretDistanceMetrics( + previousRect, + clientX, + clientY, + ); + const candidateMetrics = getCaretDistanceMetrics( + candidateRect, + clientX, + clientY, + ); + const isNearWrappedBoundary = + previousMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX && + candidateMetrics.dy <= WRAPPED_LINE_HYSTERESIS_PX; + if (!isNearWrappedBoundary) { + return candidateOffset; + } + + const shouldPreservePreviousLine = + previousMetrics.dx <= + candidateMetrics.dx + WRAPPED_LINE_HORIZONTAL_SLACK_PX && + previousMetrics.dy <= candidateMetrics.dy + WRAPPED_LINE_DELTA_PX; + return shouldPreservePreviousLine ? previousOffset : candidateOffset; +} + +export function approximateInlineOffsetFromPoint( + inlineEl: HTMLElement, + clientX: number, + clientY: number, + previousOffset?: number | null, +): number { + const textLength = getLogicalNodeLength(inlineEl); + if (textLength <= 0) return 0; + const inlineAtomOffset = getInlineAtomPointerOffset( + inlineEl, + clientX, + clientY, + ); + if (inlineAtomOffset !== null) { + return inlineAtomOffset; + } + + let bestOffset = 0; + let bestScore = Number.POSITIVE_INFINITY; + + for (let offset = 0; offset <= textLength; offset++) { + const rect = getInlineCaretRectFromOffset(inlineEl, offset); + const { dx, dy } = getCaretDistanceMetrics(rect, clientX, clientY); + const score = dy * 1000 + dx; + if (score < bestScore) { + bestScore = score; + bestOffset = offset; + } + } + + return stabilizeWrappedLineOffset( + inlineEl, + bestOffset, + clientX, + clientY, + previousOffset, + ); +} diff --git a/packages/rendering/dom/src/field-editor/selectionProjectionController.ts b/packages/rendering/dom/src/field-editor/selectionProjectionController.ts new file mode 100644 index 0000000..104d9a5 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/selectionProjectionController.ts @@ -0,0 +1,467 @@ +import type { SelectionState } from "@pen/types"; +import type { PenFieldEditorFocusOptions } from "./controller"; +import type { HistorySelectionCoordinator } from "./historySelectionCoordinator"; + +type ProgrammaticTextSelection = { + blockId: string; + anchorOffset: number; + focusOffset: number; + selectionIntentEpoch: number; +}; + +type ProjectionOptions = { + syncBackendImmediately?: boolean; +} & PenFieldEditorFocusOptions; + +type SelectionProjectionControllerOptions = { + historySelectionCoordinator: HistorySelectionCoordinator; + isEditing: () => boolean; + getMode: () => "inactive" | "single" | "expanded" | "block"; + getFocusBlockId: () => string | null; + getAttachedElement: () => HTMLElement | null; + getRootElement: () => HTMLElement | null; + findExpandedHost: () => HTMLElement | null; + resolveInlineElement: (blockId: string) => HTMLElement | null; + attachElement: ( + element: HTMLElement, + options?: PenFieldEditorFocusOptions, + ) => boolean; + requestDomFocus: ( + target: HTMLElement, + reason: "selection-project", + options?: FocusOptions, + policyOptions?: PenFieldEditorFocusOptions, + ) => boolean; + updateBackendSelection: () => void; + setTextSelection: ( + blockId: string, + anchorOffset: number, + focusOffset: number, + ) => void; + activate: (blockId: string) => void; + emitSelectionProjected: () => void; +}; + +export class SelectionProjectionController { + private readonly _historySelectionCoordinator: HistorySelectionCoordinator; + private readonly _options: SelectionProjectionControllerOptions; + private _syncDomVersion = 0; + private _suppressNextDomSelectionProjection = false; + private _pointerSelectionDepth = 0; + private _pendingSelectionProjectionVersion: number | null = null; + private _selectionIntentEpoch = 0; + private _programmaticTextSelection: ProgrammaticTextSelection | null = null; + private _pendingProgrammaticTextSelection: ProgrammaticTextSelection | null = + null; + private _committedProgrammaticTextSelection: ProgrammaticTextSelection | null = + null; + + constructor(options: SelectionProjectionControllerOptions) { + this._historySelectionCoordinator = options.historySelectionCoordinator; + this._options = options; + } + + reset(): void { + this._suppressNextDomSelectionProjection = false; + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + this._committedProgrammaticTextSelection = null; + this._pointerSelectionDepth = 0; + this._pendingSelectionProjectionVersion = null; + } + + beginPointerSelection(): void { + this.recordUserSelectionIntent(); + this._pointerSelectionDepth += 1; + } + + endPointerSelection(): void { + if (this._pointerSelectionDepth === 0) { + return; + } + this._pointerSelectionDepth -= 1; + this.recordUserSelectionIntent(); + } + + consumeDomSelectionProjectionSuppression(): boolean { + const shouldSuppress = this._suppressNextDomSelectionProjection; + this._suppressNextDomSelectionProjection = false; + return shouldSuppress; + } + + suppressNextDomSelectionProjection(): void { + this._suppressNextDomSelectionProjection = true; + } + + shouldHandleDomSelectionChange( + blockId: string | null, + isApplyingSelection: number, + ): boolean { + const hasProgrammaticSelection = + this._getActiveProgrammaticTextSelection(blockId) !== null; + const hasPendingProjection = + this._pendingSelectionProjectionVersion !== null; + return ( + isApplyingSelection === 0 && + this._pointerSelectionDepth === 0 && + (hasProgrammaticSelection || + hasPendingProjection || + !this._historySelectionCoordinator.shouldSuppressSelectionSync()) + ); + } + + resolveProgrammaticInputRange( + blockId: string | null, + liveRange: { start: number; end: number } | null, + ): { start: number; end: number } | null { + const programmaticSelection = + this._getActiveProgrammaticTextSelection(blockId); + if (!programmaticSelection) { + return null; + } + if (!liveRange) { + this._clearProgrammaticTextSelections(); + return { + start: programmaticSelection.anchorOffset, + end: programmaticSelection.focusOffset, + }; + } + if ( + liveRange.start === liveRange.end && + (liveRange.start !== programmaticSelection.anchorOffset || + liveRange.end !== programmaticSelection.focusOffset) + ) { + this._clearProgrammaticTextSelections(); + return { + start: programmaticSelection.anchorOffset, + end: programmaticSelection.focusOffset, + }; + } + return null; + } + + shouldIgnoreDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + const programmaticSelection = this._getActiveProgrammaticTextSelection( + anchor.blockId, + ); + if (!programmaticSelection || anchor.blockId !== focus.blockId) { + return false; + } + if ( + anchor.offset === programmaticSelection.anchorOffset && + focus.offset === programmaticSelection.focusOffset + ) { + return false; + } + return anchor.offset === focus.offset; + } + + isProgrammaticDomTextSelection( + anchor: { blockId: string; offset: number }, + focus: { blockId: string; offset: number }, + ): boolean { + const programmaticSelection = this._getActiveProgrammaticTextSelection( + anchor.blockId, + ); + return ( + programmaticSelection != null && + anchor.blockId === focus.blockId && + anchor.offset === programmaticSelection.anchorOffset && + focus.offset === programmaticSelection.focusOffset + ); + } + + prepareSyncedTextSelection( + currentSelection: SelectionState | null, + blockId: string, + anchorOffset: number, + focusOffset: number, + ): "skip" | "apply" { + const pendingProgrammaticSelection = + this._pendingProgrammaticTextSelection; + const isAlreadyCurrentSelection = + currentSelection?.type === "text" && + !currentSelection.isMultiBlock && + currentSelection.anchor.blockId === blockId && + currentSelection.focus.blockId === blockId && + currentSelection.anchor.offset === anchorOffset && + currentSelection.focus.offset === focusOffset; + if (isAlreadyCurrentSelection) { + if ( + pendingProgrammaticSelection && + pendingProgrammaticSelection.blockId === blockId && + pendingProgrammaticSelection.anchorOffset === anchorOffset && + pendingProgrammaticSelection.focusOffset === focusOffset + ) { + this._pendingProgrammaticTextSelection = null; + } + return "skip"; + } + + if ( + pendingProgrammaticSelection && + (pendingProgrammaticSelection.blockId !== blockId || + pendingProgrammaticSelection.anchorOffset !== anchorOffset || + pendingProgrammaticSelection.focusOffset !== focusOffset) + ) { + this.recordUserSelectionIntent(); + } else if (!pendingProgrammaticSelection) { + this._selectionIntentEpoch += 1; + } + return "apply"; + } + + notifyTextSelectionSet( + blockId: string, + anchorOffset: number, + focusOffset: number, + ): void { + const programmaticSelection = this._programmaticTextSelection; + if ( + programmaticSelection && + (programmaticSelection.blockId !== blockId || + programmaticSelection.anchorOffset !== anchorOffset || + programmaticSelection.focusOffset !== focusOffset) + ) { + this._programmaticTextSelection = null; + } + const pendingProgrammaticSelection = + this._pendingProgrammaticTextSelection; + if ( + pendingProgrammaticSelection && + (pendingProgrammaticSelection.blockId !== blockId || + pendingProgrammaticSelection.anchorOffset !== anchorOffset || + pendingProgrammaticSelection.focusOffset !== focusOffset) + ) { + this._pendingProgrammaticTextSelection = null; + } + if ( + programmaticSelection && + programmaticSelection.blockId === blockId && + programmaticSelection.anchorOffset === anchorOffset && + programmaticSelection.focusOffset === focusOffset + ) { + this._committedProgrammaticTextSelection = programmaticSelection; + } + } + + activateTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: PenFieldEditorFocusOptions, + ): void { + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + this._committedProgrammaticTextSelection = null; + this.projectTextSelection(blockId, anchorOffset, focusOffset, options); + } + + commitProgrammaticTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options: PenFieldEditorFocusOptions = {}, + ): void { + this._programmaticTextSelection = { + blockId, + anchorOffset, + focusOffset, + selectionIntentEpoch: this._selectionIntentEpoch, + }; + this._pendingProgrammaticTextSelection = { + blockId, + anchorOffset, + focusOffset, + selectionIntentEpoch: this._selectionIntentEpoch, + }; + this._committedProgrammaticTextSelection = null; + this.projectTextSelection(blockId, anchorOffset, focusOffset, { + ...options, + syncBackendImmediately: true, + }); + } + + projectTextSelection( + blockId: string, + anchorOffset: number, + focusOffset: number, + options?: ProjectionOptions, + ): void { + this._options.setTextSelection(blockId, anchorOffset, focusOffset); + + if ( + !this._options.isEditing() || + this._options.getFocusBlockId() !== blockId + ) { + this._options.activate(blockId); + } + + if (options?.syncBackendImmediately ?? true) { + this._options.updateBackendSelection(); + } + this.syncDomSelectionOnce(4, undefined, options); + } + + syncDomSelectionOnce( + remainingAttempts = 4, + version?: number, + options: PenFieldEditorFocusOptions = {}, + selectionIntentEpoch = this._selectionIntentEpoch, + ): void { + if (version === undefined) { + version = ++this._syncDomVersion; + this._pendingSelectionProjectionVersion = version; + } + const v = version; + requestAnimationFrame(() => { + if (!this._options.isEditing() || this._syncDomVersion !== v) + return; + if (selectionIntentEpoch !== this._selectionIntentEpoch) { + this._cancelSelectionProjection(v); + return; + } + + let projected = false; + const pendingProjectionRequestId = + this._historySelectionCoordinator.getPendingProjectionRequestId(); + + if (this._options.getMode() === "expanded") { + const expandedHost = this._options.findExpandedHost(); + if (expandedHost) { + projected = this._projectIntoElement(expandedHost, options); + } + } else { + const focusBlockId = this._options.getFocusBlockId(); + if (focusBlockId) { + const inlineEl = + this._options.resolveInlineElement(focusBlockId); + if (inlineEl) { + projected = this._projectIntoElement(inlineEl, options); + } + } + } + + if (projected) { + this._options.emitSelectionProjected(); + requestAnimationFrame(() => { + if (this._syncDomVersion === v) { + if (this._pendingSelectionProjectionVersion === v) { + this._pendingSelectionProjectionVersion = null; + } + this._historySelectionCoordinator.completeDeferredProjection( + pendingProjectionRequestId, + ); + } + }); + } + + if (!projected && remainingAttempts > 0) { + this.syncDomSelectionOnce( + remainingAttempts - 1, + v, + options, + selectionIntentEpoch, + ); + } else if (!projected) { + this._cancelSelectionProjection(v); + } + }); + } + + shouldProjectSelectionAfterReconcile(): boolean { + const attachedElement = this._options.getAttachedElement(); + if (!attachedElement) { + return false; + } + + const ownerDocument = attachedElement.ownerDocument; + const activeElement = ownerDocument?.activeElement; + if (!(activeElement instanceof Node)) { + return true; + } + if (activeElement === ownerDocument?.body) { + return true; + } + + const root = this._options.getRootElement(); + if (!root || !root.contains(activeElement)) { + return true; + } + + return attachedElement.contains(activeElement); + } + + recordUserSelectionIntent(): void { + this._selectionIntentEpoch += 1; + this._clearProgrammaticTextSelections(); + const pendingProjectionVersion = + this._pendingSelectionProjectionVersion; + if (pendingProjectionVersion !== null) { + this._syncDomVersion += 1; + this._cancelSelectionProjection(pendingProjectionVersion); + } + } + + shouldSuppressSelectionSync(): boolean { + return ( + this._historySelectionCoordinator.shouldSuppressSelectionSync() || + this._pendingSelectionProjectionVersion !== null + ); + } + + private _projectIntoElement( + element: HTMLElement, + options: PenFieldEditorFocusOptions, + ): boolean { + let didAttach = true; + const attachedElement = this._options.getAttachedElement(); + if (attachedElement !== element || !attachedElement?.isConnected) { + didAttach = this._options.attachElement(element, options); + } + if ( + didAttach && + this._options.requestDomFocus( + element, + "selection-project", + { + preventScroll: true, + }, + options, + ) + ) { + this._options.updateBackendSelection(); + return true; + } + return false; + } + + private _cancelSelectionProjection(version: number): void { + if (this._pendingSelectionProjectionVersion === version) { + this._pendingSelectionProjectionVersion = null; + } + this._historySelectionCoordinator.cancelDeferredProjection(); + } + + private _getActiveProgrammaticTextSelection( + blockId: string | null, + ): ProgrammaticTextSelection | null { + const programmaticSelection = + this._programmaticTextSelection ?? + this._pendingProgrammaticTextSelection ?? + this._committedProgrammaticTextSelection; + if (!blockId || programmaticSelection?.blockId !== blockId) { + return null; + } + return programmaticSelection; + } + + private _clearProgrammaticTextSelections(): void { + this._programmaticTextSelection = null; + this._pendingProgrammaticTextSelection = null; + this._committedProgrammaticTextSelection = null; + } +} diff --git a/packages/rendering/dom/src/field-editor/sessionReconciler.ts b/packages/rendering/dom/src/field-editor/sessionReconciler.ts index fdb77c4..979531e 100644 --- a/packages/rendering/dom/src/field-editor/sessionReconciler.ts +++ b/packages/rendering/dom/src/field-editor/sessionReconciler.ts @@ -17,6 +17,7 @@ interface SessionReconcilerOptions { shouldPreserveSelection: () => boolean; shouldProjectSelection: () => boolean; projectSelection: () => void; + notifyDomReconciled?: (blockId: string) => void; } export class SessionReconciler { @@ -160,7 +161,10 @@ export class SessionReconciler { } this.reconcileBlock(blockId, preserveSelection); } - if (shouldProjectSelection && this.options.shouldProjectSelection()) { + if ( + shouldProjectSelection && + this.options.shouldProjectSelection() + ) { this.options.projectSelection(); } return; @@ -183,6 +187,7 @@ export class SessionReconciler { preserveSelection, inlineDecorations: this.getInlineDecorations(blockId), }); + this.options.notifyDomReconciled?.(blockId); continue; } this.reconcileBlock(blockId, preserveSelection); @@ -202,6 +207,7 @@ export class SessionReconciler { preserveSelection, inlineDecorations: this.getInlineDecorations(blockId), }); + this.options.notifyDomReconciled?.(blockId); } private getInlineDecorations(blockId: string): readonly InlineDecoration[] { @@ -209,7 +215,8 @@ export class SessionReconciler { .getDecorations() .forBlock(blockId) .filter( - (decoration): decoration is InlineDecoration => decoration.type === "inline", + (decoration): decoration is InlineDecoration => + decoration.type === "inline", ); } } diff --git a/packages/rendering/dom/src/field-editor/store.ts b/packages/rendering/dom/src/field-editor/store.ts index a443ef8..287f045 100644 --- a/packages/rendering/dom/src/field-editor/store.ts +++ b/packages/rendering/dom/src/field-editor/store.ts @@ -6,6 +6,7 @@ export interface FieldEditorStoreSnapshot { isEditing: boolean; isFocused: boolean; isComposing: boolean; + domSyncVersion: number; inputMode: "richtext" | "code" | "table" | "none"; mode: "inactive" | "single" | "expanded" | "block"; activeCellCoord: { blockId: string; row: number; col: number } | null; @@ -26,4 +27,5 @@ export interface FieldEditorStore extends FieldEditor { }, ): void; collapseSelectionToPoint(point: { blockId: string; offset: number }): void; + notifyDomReconciled(blockId?: string): void; } diff --git a/packages/rendering/dom/src/field-editor/textDiff.ts b/packages/rendering/dom/src/field-editor/textDiff.ts new file mode 100644 index 0000000..2e96777 --- /dev/null +++ b/packages/rendering/dom/src/field-editor/textDiff.ts @@ -0,0 +1,51 @@ +import { getLogicalTextContent } from "./inlineAtomDom"; + +export type TextDiffOp = + | { type: "insert"; offset: number; text: string } + | { type: "delete"; offset: number; length: number }; + +/** + * O(n) scan from both ends to find the changed region. + * Returns delete + insert ops for the diff. + */ +export function computeTextDiff( + oldText: string, + newText: string, +): TextDiffOp[] { + if (oldText === newText) return []; + + let prefixLen = 0; + const minLen = Math.min(oldText.length, newText.length); + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } + + let oldSuffix = oldText.length; + let newSuffix = newText.length; + while ( + oldSuffix > prefixLen && + newSuffix > prefixLen && + oldText[oldSuffix - 1] === newText[newSuffix - 1] + ) { + oldSuffix--; + newSuffix--; + } + + const ops: TextDiffOp[] = []; + + const deleteLen = oldSuffix - prefixLen; + if (deleteLen > 0) { + ops.push({ type: "delete", offset: prefixLen, length: deleteLen }); + } + + const insertText = newText.slice(prefixLen, newSuffix); + if (insertText.length > 0) { + ops.push({ type: "insert", offset: prefixLen, text: insertText }); + } + + return ops; +} + +export function extractTextFromDOM(element: HTMLElement): string { + return getLogicalTextContent(element); +} diff --git a/packages/rendering/dom/src/field-editor/textInputPipeline.ts b/packages/rendering/dom/src/field-editor/textInputPipeline.ts new file mode 100644 index 0000000..abfbe6f --- /dev/null +++ b/packages/rendering/dom/src/field-editor/textInputPipeline.ts @@ -0,0 +1,124 @@ +import type { DocumentOp, Editor } from "@pen/types"; +import type { FieldEditorInputController, ActiveCellCoord } from "./controller"; +import type { FieldEditorTextLike } from "./crdt"; +import { + buildInlineTextDiffOps, + buildInlineTextEditTransaction, + type InlineTextDiffOp, + type InlineTextRange, + type InlineTextSelectionTarget, +} from "./inlineTextTransaction"; + +type TextInputPipelineController = Pick< + FieldEditorInputController, + | "setBackendSelectionAuthority" + | "syncTextSelection" + | "resolveInsertMarks" +>; + +export interface ApplyInlineTextInputOptions { + editor: Editor; + fieldEditor: TextInputPipelineController; + blockId: string; + range: InlineTextRange; + text: string; + marks?: Record; + cellCoord?: ActiveCellCoord | null; + selection?: InlineTextSelectionTarget | null; + syncSelection?: boolean; +} + +export interface ApplyInlineTextDiffInputOptions { + editor: Editor; + fieldEditor: TextInputPipelineController; + blockId: string; + diff: readonly InlineTextDiffOp[]; + ytext: FieldEditorTextLike; + selection?: InlineTextSelectionTarget | null; + cellCoord?: ActiveCellCoord | null; +} + +export interface ApplyInlineTextDiffInputResult { + applied: boolean; + selection: InlineTextSelectionTarget | null; +} + +export function applyInlineTextInput( + options: ApplyInlineTextInputOptions, +): InlineTextSelectionTarget { + const transaction = buildInlineTextEditTransaction({ + blockId: options.blockId, + range: options.range, + text: options.text, + marks: options.marks, + cellCoord: options.cellCoord, + }); + const selection = options.selection ?? transaction.selection; + if (options.syncSelection === false) { + if (transaction.ops.length > 0) { + options.editor.apply(transaction.ops, { origin: "user" }); + } + return selection; + } + applyInlineTextOperations(options, transaction.ops, selection); + return selection; +} + +export function applyInlineTextDiffInput( + options: ApplyInlineTextDiffInputOptions, +): ApplyInlineTextDiffInputResult { + if (options.diff.length === 0) { + return { applied: false, selection: null }; + } + + const ops = buildInlineTextDiffOps({ + blockId: options.blockId, + diff: options.diff, + ytext: options.ytext, + resolveInsertMarks: (sourceText, offset) => + options.fieldEditor.resolveInsertMarks(sourceText, offset), + cellCoord: options.cellCoord, + }); + if (ops.length === 0) { + return { applied: false, selection: null }; + } + + if (!options.selection) { + options.editor.apply(ops, { origin: "user" }); + return { applied: true, selection: null }; + } + + applyInlineTextOperations(options, ops, options.selection); + return { applied: true, selection: options.selection }; +} + +function applyInlineTextOperations( + options: { + editor: Editor; + fieldEditor: TextInputPipelineController; + blockId: string; + cellCoord?: ActiveCellCoord | null; + }, + ops: readonly DocumentOp[], + selection: InlineTextSelectionTarget, +): void { + options.fieldEditor.setBackendSelectionAuthority( + "programmatic", + selection, + ); + + if (ops.length > 0) { + options.editor.apply([...ops], { origin: "user" }); + } + + if (options.cellCoord) { + options.fieldEditor.setBackendSelectionAuthority("cell", selection); + return; + } + + options.fieldEditor.syncTextSelection( + options.blockId, + selection.anchorOffset, + selection.focusOffset, + ); +} diff --git a/packages/rendering/dom/src/field-editor/transferBlocks.ts b/packages/rendering/dom/src/field-editor/transferBlocks.ts index 31a24b4..db9abcd 100644 --- a/packages/rendering/dom/src/field-editor/transferBlocks.ts +++ b/packages/rendering/dom/src/field-editor/transferBlocks.ts @@ -55,34 +55,34 @@ export function pasteBlocks( const insertBlockOp = previousBlockId ? ({ - type: "insert-block", - blockId, - blockType: block.type!, - props: block.props ?? {}, - position: { after: previousBlockId } as Position, - } as DocumentOp) + type: "insert-block", + blockId, + blockType: block.type!, + props: block.props ?? {}, + position: { after: previousBlockId } as Position, + } as DocumentOp) : cursor ? shouldReplaceEmpty ? ({ - type: "insert-block", - blockId, - blockType: block.type!, - props: block.props ?? {}, - position: { before: cursor.blockId } as Position, - } as DocumentOp) - : getInsertSiblingBlockOp(editor, { - siblingBlockId: cursor.blockId, - blockId, - blockType: block.type!, - props: block.props ?? {}, - }) - : ({ type: "insert-block", blockId, blockType: block.type!, props: block.props ?? {}, - position: "last" as Position, - } as DocumentOp); + position: { before: cursor.blockId } as Position, + } as DocumentOp) + : getInsertSiblingBlockOp(editor, { + siblingBlockId: cursor.blockId, + blockId, + blockType: block.type!, + props: block.props ?? {}, + }) + : ({ + type: "insert-block", + blockId, + blockType: block.type!, + props: block.props ?? {}, + position: "last" as Position, + } as DocumentOp); ops.push(insertBlockOp); @@ -179,18 +179,18 @@ export function pasteInlineText( ops.push({ ...(previousBlockId === blockId ? getInsertSiblingBlockOp(editor, { - siblingBlockId: previousBlockId, - blockId: newId, - blockType, - props: {}, - }) + siblingBlockId: previousBlockId, + blockId: newId, + blockType, + props: {}, + }) : { - type: "insert-block", - blockId: newId, - blockType, - props: {}, - position: { after: previousBlockId }, - }), + type: "insert-block", + blockId: newId, + blockType, + props: {}, + position: { after: previousBlockId }, + }), }); if (lineText) { diff --git a/packages/rendering/dom/src/index.ts b/packages/rendering/dom/src/index.ts index 7eb8bab..8c02622 100644 --- a/packages/rendering/dom/src/index.ts +++ b/packages/rendering/dom/src/index.ts @@ -1,5 +1,29 @@ export { FieldEditorImpl } from "./field-editor/fieldEditorImpl"; -export type { FieldEditorSession } from "./field-editor/controller"; +export type { + FieldEditorFocusReason, + FieldEditorFocusRequest, + FieldEditorSession, + PenFocusAction, + PenFocusDecision, + PenFieldEditorFocusOptions, + PenFocusLifecycleEvent, + PenFocusLifecycleListener, + PenFocusPolicy, + PenFocusRequest, + PenFocusReason, +} from "./field-editor/controller"; +export { handleEditorDocumentKeyDown } from "./utils/documentShortcuts"; +export { handleEscapeSelectionTransition } from "./utils/escapeSelection"; +export { handleTableCellSelectionKeyDown } from "./utils/tableCellNavigation"; +export { + getClosestEditorRoot, + isActiveFieldEditorTextEntryTarget, + isFieldEditorTextEditingKey, + isFieldEditorTextEntryTarget, + isNativeTextEntryTarget, + isTextEntryTarget, + shouldHandleEditorKeyboardEvent, +} from "./utils/textEntryTarget"; export { DEFAULT_SELECT_ALL_BEHAVIOR, resolveSelectAllBehavior, diff --git a/packages/rendering/dom/src/internal/inputBackend.ts b/packages/rendering/dom/src/internal/inputBackend.ts new file mode 100644 index 0000000..fce15f1 --- /dev/null +++ b/packages/rendering/dom/src/internal/inputBackend.ts @@ -0,0 +1,5 @@ +export interface InputBackend { + activate(element: HTMLElement, ytext: unknown): void; + deactivate(): void; + updateSelection(relPos: unknown): void; +} diff --git a/packages/rendering/dom/src/utils/blockSelectionSemantics.ts b/packages/rendering/dom/src/utils/blockSelectionSemantics.ts index 9e14c20..38b33ad 100644 --- a/packages/rendering/dom/src/utils/blockSelectionSemantics.ts +++ b/packages/rendering/dom/src/utils/blockSelectionSemantics.ts @@ -5,6 +5,8 @@ import { } from "@pen/types"; export type { BlockSelectionRole } from "@pen/types"; +const ZERO_WIDTH_SPACE = "\u200B"; + export function getBlockSelectionRoleFromSchema( schema: Parameters[0], ): BlockSelectionRole | null { @@ -51,10 +53,25 @@ export function getEditorBlockSelectionLength( return getSelectionLengthForRole( getEditorBlockSelectionRole(editor, blockId), - block.textContent().length, + getLogicalBlockTextLength(block), ); } +function getLogicalBlockTextLength( + block: NonNullable>, +): number { + return block + .inlineDeltas() + .reduce( + (length, delta) => + length + + (typeof delta.insert === "string" + ? delta.insert.replaceAll(ZERO_WIDTH_SPACE, "").length + : 1), + 0, + ); +} + export function isInlineEditableBlock( editor: Editor, blockId: string, diff --git a/packages/rendering/dom/src/utils/clipboardSerialization.ts b/packages/rendering/dom/src/utils/clipboardSerialization.ts index 8843e63..c9403ca 100644 --- a/packages/rendering/dom/src/utils/clipboardSerialization.ts +++ b/packages/rendering/dom/src/utils/clipboardSerialization.ts @@ -44,7 +44,7 @@ export function writePenClipboard( }), ]) .catch(() => { - navigator.clipboard.writeText(plainText).catch(() => {}); + navigator.clipboard.writeText(plainText).catch(() => { }); }); } diff --git a/packages/rendering/dom/src/utils/dataAttributes.ts b/packages/rendering/dom/src/utils/dataAttributes.ts index 846a553..03f973e 100644 --- a/packages/rendering/dom/src/utils/dataAttributes.ts +++ b/packages/rendering/dom/src/utils/dataAttributes.ts @@ -21,6 +21,12 @@ export const DATA_ATTRS = { viewId: "data-pen-view-id", editorBlock: "data-pen-editor-block", inlineContent: "data-pen-inline-content", + inlineAtom: "data-pen-inline-atom", + inlineAtomHost: "data-pen-inline-atom-host", + inlineAtomType: "data-pen-inline-atom-type", + inlineAtomProps: "data-pen-inline-atom-props", + inlineAtomCaretBoundary: "data-pen-inline-atom-caret-boundary", + inlineAtomCaretSide: "data-pen-inline-atom-caret-side", fieldEditorSurface: "data-pen-field-editor-surface", fieldEditorActiveSurface: "data-pen-field-editor-active-surface", fieldEditor: "data-pen-field-editor", diff --git a/packages/rendering/dom/src/utils/documentShortcuts.ts b/packages/rendering/dom/src/utils/documentShortcuts.ts new file mode 100644 index 0000000..055beb5 --- /dev/null +++ b/packages/rendering/dom/src/utils/documentShortcuts.ts @@ -0,0 +1,334 @@ +import { + generateId, + type Editor, + type InteractionModel, + usesInlineTextSelection, +} from "@pen/types"; +import type { FieldEditorSession } from "../field-editor/controller"; +import { + handleHistoryShortcut, + handleSelectAllShortcut, +} from "../field-editor/keyHandling"; +import { DATA_ATTRS } from "./dataAttributes"; +import { handleEscapeSelectionTransition } from "./escapeSelection"; +import { getAdjacentVisibleBlockId } from "./parentIdTree"; +import { handleTableCellSelectionKeyDown } from "./tableCellNavigation"; + +const DATABASE_ROW_SELECTION_SLOT = "database:row-selection"; +const ZERO_WIDTH_SPACE = "\u200B"; + +type DatabaseRowSelectionController = { + deleteSelectedRows: (blockId: string) => boolean; +}; + +export function handleEditorDocumentKeyDown(options: { + event: KeyboardEvent; + editor: Editor; + fieldEditor: FieldEditorSession; + interactionModel?: InteractionModel; + root: HTMLElement; +}): boolean { + const { event, editor, fieldEditor, interactionModel, root } = options; + + return ( + handleEscapeSelectionTransition({ event, editor, fieldEditor, root }) || + handleDeleteSelectionShortcut(event, editor, fieldEditor, root) || + handleTableCellSelectionKeyDown({ event, editor, fieldEditor, root }) || + handleSelectAllShortcut(editor, event, fieldEditor, { + rootElement: root, + }) || + handleBlockSelectionEnter( + event, + editor, + fieldEditor, + interactionModel, + ) || + handleBlockSelectionArrow(event, editor, fieldEditor) || + handleHistoryShortcut(editor, event) + ); +} + +function handleBlockSelectionArrow( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorSession, +): boolean { + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing + ) { + return false; + } + + const isUp = event.key === "ArrowUp" || event.key === "ArrowLeft"; + const isDown = event.key === "ArrowDown" || event.key === "ArrowRight"; + if (!isUp && !isDown) return false; + + const selection = editor.selection; + if (selection?.type !== "block" || selection.blockIds.length === 0) { + return false; + } + + const blockId = isUp + ? selection.blockIds[0]! + : selection.blockIds[selection.blockIds.length - 1]!; + const direction = isUp ? "previous" : "next"; + + const adjacentId = getAdjacentVisibleBlockId(editor, blockId, direction); + if (!adjacentId) return false; + + const adjacentBlock = editor.getBlock(adjacentId); + if (!adjacentBlock) return false; + + const schema = editor.schema.resolve(adjacentBlock.type); + if (usesInlineTextSelection(schema)) { + const offset = isUp ? adjacentBlock.length() : 0; + fieldEditor.activateTextSelection(adjacentId, offset, offset); + return true; + } + + editor.selectBlock(adjacentId); + return true; +} + +function handleBlockSelectionEnter( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorSession, + interactionModel: InteractionModel = "content-first", +): boolean { + if ( + event.key !== "Enter" || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing + ) { + return false; + } + + const selection = editor.selection; + if (selection?.type !== "block" || selection.blockIds.length === 0) { + return false; + } + + const anchorBlockId = selection.blockIds[selection.blockIds.length - 1]!; + const anchorBlock = editor.getBlock(anchorBlockId); + if (!anchorBlock) { + return false; + } + const anchorSchema = editor.schema.resolve(anchorBlock.type); + + if ( + interactionModel === "block-first" && + selection.blockIds.length === 1 && + usesInlineTextSelection(anchorSchema) + ) { + const offset = anchorBlock.length(); + fieldEditor.activateTextSelection(anchorBlockId, offset, offset); + return true; + } + + const newBlockId = generateId(); + + editor.apply( + [ + { + type: "insert-block", + blockId: newBlockId, + blockType: "paragraph", + props: {}, + position: { after: anchorBlockId }, + }, + ], + { origin: "user" }, + ); + + fieldEditor.activateTextSelection(newBlockId, 0, 0); + return true; +} + +function handleDeleteSelectionShortcut( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorSession, + root: HTMLElement, +): boolean { + if ( + (event.key !== "Backspace" && event.key !== "Delete") || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.isComposing || + fieldEditor.isComposing + ) { + return false; + } + + const selection = editor.selection; + if (tryDeleteSelectedDatabaseRows(root, editor)) { + fieldEditor.deactivate(); + return true; + } + if (!selection) { + return false; + } + + if (selection.type === "text" && !selection.isCollapsed) { + if ( + !selection.isMultiBlock && + !textSelectionContainsInlineAtom(editor, selection) && + !shouldUseDocumentTextDeletionFallback(root, fieldEditor) + ) { + return false; + } + if (selection.isMultiBlock) { + fieldEditor.deactivate(); + } + editor.deleteSelection({ origin: "user" }); + const nextSelection = editor.selection; + if (nextSelection?.type === "text") { + fieldEditor.activateTextSelection( + nextSelection.focus.blockId, + nextSelection.focus.offset, + nextSelection.focus.offset, + ); + } else { + fieldEditor.deactivate(); + } + return true; + } + + if (selection.type === "block" && selection.blockIds.length > 0) { + editor.deleteSelection({ origin: "user" }); + fieldEditor.deactivate(); + const firstBlock = editor.firstBlock(); + if (firstBlock) { + const schema = editor.schema.resolve(firstBlock.type); + if (usesInlineTextSelection(schema)) { + fieldEditor.activateTextSelection(firstBlock.id, 0, 0); + } + } + return true; + } + + if (selection.type === "cell") { + editor.deleteSelection({ origin: "user" }); + return true; + } + + return false; +} + +function textSelectionContainsInlineAtom( + editor: Editor, + selection: Extract, { type: "text" }>, +): boolean { + if ( + selection.isMultiBlock || + selection.anchor.blockId !== selection.focus.blockId + ) { + return false; + } + + const block = editor.getBlock(selection.anchor.blockId); + if (!block) { + return false; + } + + const selectionStart = Math.min( + selection.anchor.offset, + selection.focus.offset, + ); + const selectionEnd = Math.max( + selection.anchor.offset, + selection.focus.offset, + ); + if (selectionEnd <= selectionStart) { + return false; + } + + let offset = 0; + for (const delta of block.inlineDeltas()) { + const length = + typeof delta.insert === "string" + ? delta.insert.replaceAll(ZERO_WIDTH_SPACE, "").length + : 1; + const overlapsSelection = + offset < selectionEnd && offset + length > selectionStart; + if (typeof delta.insert !== "string" && overlapsSelection) { + return true; + } + offset += length; + } + + return false; +} + +function tryDeleteSelectedDatabaseRows( + root: HTMLElement, + editor: Editor, +): boolean { + const controller = editor.internals.getSlot(DATABASE_ROW_SELECTION_SLOT) as + | DatabaseRowSelectionController + | undefined; + if (!controller) { + return false; + } + + const activeElement = root.ownerDocument?.activeElement; + if ( + !(activeElement instanceof HTMLElement) || + !root.contains(activeElement) + ) { + return false; + } + + const blockElement = activeElement.closest("[data-block-id]"); + const blockId = blockElement?.getAttribute("data-block-id"); + if (!blockId) { + return false; + } + + const block = editor.getBlock(blockId); + if (!block || block.type !== "database") { + return false; + } + + return controller.deleteSelectedRows(blockId); +} + +function shouldUseDocumentTextDeletionFallback( + root: HTMLElement, + fieldEditor: FieldEditorSession, +): boolean { + if (!fieldEditor.isEditing) { + return true; + } + + const activeElement = root.ownerDocument?.activeElement; + if ( + !(activeElement instanceof HTMLElement) || + !root.contains(activeElement) + ) { + return true; + } + + if (activeElement === root) { + return true; + } + + const activeInlineSurface = activeElement.closest( + `[${DATA_ATTRS.inlineContent}]`, + ); + if (activeInlineSurface === null) { + return true; + } + + return false; +} diff --git a/packages/rendering/react/src/utils/escapeSelection.ts b/packages/rendering/dom/src/utils/escapeSelection.ts similarity index 100% rename from packages/rendering/react/src/utils/escapeSelection.ts rename to packages/rendering/dom/src/utils/escapeSelection.ts diff --git a/packages/rendering/dom/src/utils/inlineDecorations.ts b/packages/rendering/dom/src/utils/inlineDecorations.ts index 5c3fa2c..608d65c 100644 --- a/packages/rendering/dom/src/utils/inlineDecorations.ts +++ b/packages/rendering/dom/src/utils/inlineDecorations.ts @@ -1,33 +1,86 @@ import type { InlineDecoration } from "@pen/types"; +import { DECORATION_OMIT_FROM_RENDER_ATTRIBUTE } from "@pen/types"; const INLINE_DECORATION_ATTRIBUTE_KEY = "__penInlineDecoration"; +const VIRTUAL_INLINE_DECORATION_ATTRIBUTE = "data-pen-virtual-inline"; interface TextDelta { - insert: string; + insert: string | Record; attributes?: Readonly>; } +type VirtualInlineDecoration = InlineDecoration & { + virtualText?: string; + virtualPlacement?: "before" | "after"; +}; + export function applyInlineDecorationsToDeltas( deltas: readonly TextDelta[], decorations: readonly InlineDecoration[], ): TextDelta[] { - if (deltas.length === 0 || decorations.length === 0) { + if (decorations.length === 0) { return [...deltas]; } const normalizedDecorations = decorations .filter((decoration) => decoration.to > decoration.from) .sort((left, right) => - left.from === right.from ? left.to - right.to : left.from - right.from, + left.from === right.from + ? left.to - right.to + : left.from - right.from, ); - if (normalizedDecorations.length === 0) { + const virtualDecorations = decorations + .flatMap((decoration) => { + const virtualDecoration = decoration as VirtualInlineDecoration; + const text = virtualDecoration.virtualText; + if (!text) { + return []; + } + return [{ + decoration, + offset: + virtualDecoration.virtualPlacement === "before" + ? virtualDecoration.from + : virtualDecoration.to, + text, + }]; + }) + .sort((left, right) => left.offset - right.offset); + if (normalizedDecorations.length === 0 && virtualDecorations.length === 0) { return [...deltas]; } const result: TextDelta[] = []; let offset = 0; + let virtualIndex = 0; + + const appendVirtualDecorationsAt = (targetOffset: number) => { + while ( + virtualIndex < virtualDecorations.length && + virtualDecorations[virtualIndex]!.offset === targetOffset + ) { + const { decoration, text } = virtualDecorations[virtualIndex]!; + appendDelta(result, { + insert: text, + attributes: mergeDeltaAttributes(undefined, { + ...decoration.attributes, + [VIRTUAL_INLINE_DECORATION_ATTRIBUTE]: true, + }), + }); + virtualIndex += 1; + } + }; for (const delta of deltas) { + appendVirtualDecorationsAt(offset); + + if (typeof delta.insert !== "string") { + result.push({ ...delta }); + offset += 1; + appendVirtualDecorationsAt(offset); + continue; + } + const text = delta.insert; const textLength = text.length; if (textLength === 0) { @@ -39,17 +92,28 @@ export function applyInlineDecorationsToDeltas( const boundaries = new Set([segmentStart, segmentEnd]); for (const decoration of normalizedDecorations) { - if (decoration.to <= segmentStart || decoration.from >= segmentEnd) { + if ( + decoration.to <= segmentStart || + decoration.from >= segmentEnd + ) { continue; } boundaries.add(Math.max(decoration.from, segmentStart)); boundaries.add(Math.min(decoration.to, segmentEnd)); } + for (const { offset: virtualOffset } of virtualDecorations) { + if (virtualOffset >= segmentStart && virtualOffset <= segmentEnd) { + boundaries.add(virtualOffset); + } + } - const sortedBoundaries = [...boundaries].sort((left, right) => left - right); + const sortedBoundaries = [...boundaries].sort( + (left, right) => left - right, + ); for (let index = 0; index < sortedBoundaries.length - 1; index += 1) { const from = sortedBoundaries[index]; const to = sortedBoundaries[index + 1]; + appendVirtualDecorationsAt(from); if (to <= from) { continue; } @@ -64,20 +128,106 @@ export function applyInlineDecorationsToDeltas( from, to, ); - const attributes = mergeDeltaAttributes(delta.attributes, decorationAttributes); + const attributes = mergeDeltaAttributes( + delta.attributes, + decorationAttributes, + ); appendDelta(result, { insert: slice, ...(attributes ? { attributes } : {}), }); } + appendVirtualDecorationsAt(segmentEnd); offset = segmentEnd; } + while (virtualIndex < virtualDecorations.length) { + const { decoration, text } = virtualDecorations[virtualIndex]!; + appendDelta(result, { + insert: text, + attributes: mergeDeltaAttributes(undefined, { + ...decoration.attributes, + [VIRTUAL_INLINE_DECORATION_ATTRIBUTE]: true, + }), + }); + virtualIndex += 1; + } return result; } -export { INLINE_DECORATION_ATTRIBUTE_KEY }; +export function filterVisibleInlineDecorationDeltas( + deltas: readonly TextDelta[], +): TextDelta[] { + let filteredDeltas: TextDelta[] | null = null; + + for (let index = 0; index < deltas.length; index += 1) { + const delta = deltas[index]!; + const decorationAttributes = + delta.attributes?.[INLINE_DECORATION_ATTRIBUTE_KEY]; + if (!decorationAttributes || typeof decorationAttributes !== "object") { + filteredDeltas?.push(delta); + continue; + } + const isHidden = + (decorationAttributes as Record)[ + DECORATION_OMIT_FROM_RENDER_ATTRIBUTE + ] === true; + if (!isHidden) { + filteredDeltas?.push(delta); + continue; + } + filteredDeltas = filteredDeltas ?? deltas.slice(0, index); + } + + return filteredDeltas ?? (deltas as TextDelta[]); +} + +export function inlineDecorationsRequireFullReconcile( + decorations: readonly InlineDecoration[], +): boolean { + return decorations.some((decoration) => { + if ("virtualText" in decoration && decoration.virtualText) { + return true; + } + if (decoration.omitFromRender === true) { + return true; + } + const attributes = decoration.attributes; + if ( + attributes && + attributes[DECORATION_OMIT_FROM_RENDER_ATTRIBUTE] === true + ) { + return true; + } + return false; + }); +} + +export function serializeInlineDecorationForRender( + decoration: InlineDecoration, +): unknown[] { + return [ + decoration.blockId, + decoration.from, + decoration.to, + decoration.key ?? null, + decoration.omitFromRender ?? null, + "virtualText" in decoration ? decoration.virtualText : null, + "virtualPlacement" in decoration ? decoration.virtualPlacement : null, + decoration.attributes, + ]; +} + +export function buildInlineDecorationsRenderSignature( + decorations: readonly InlineDecoration[], +): string { + return JSON.stringify( + decorations.map(serializeInlineDecorationForRender), + ); +} + +export { INLINE_DECORATION_ATTRIBUTE_KEY, VIRTUAL_INLINE_DECORATION_ATTRIBUTE }; function mergeDecorationAttributes( decorations: readonly InlineDecoration[], @@ -93,6 +243,9 @@ function mergeDecorationAttributes( mergedAttributes = { ...(mergedAttributes ?? {}), ...decoration.attributes, + ...(decoration.omitFromRender + ? { [DECORATION_OMIT_FROM_RENDER_ATTRIBUTE]: true } + : {}), }; } @@ -120,6 +273,8 @@ function appendDelta(target: TextDelta[], nextDelta: TextDelta): void { const previousDelta = target[target.length - 1]; if ( previousDelta && + typeof previousDelta.insert === "string" && + typeof nextDelta.insert === "string" && attributesEqual(previousDelta.attributes, nextDelta.attributes) ) { previousDelta.insert += nextDelta.insert; diff --git a/packages/rendering/dom/src/utils/tableCellClipboard.ts b/packages/rendering/dom/src/utils/tableCellClipboard.ts new file mode 100644 index 0000000..0a25928 --- /dev/null +++ b/packages/rendering/dom/src/utils/tableCellClipboard.ts @@ -0,0 +1,194 @@ +import type { CellSelection, DocumentOp, Editor } from "@pen/types"; +import { + resolveCellSelectionCoord, + resolveCellSelectionMatrix, +} from "@pen/core"; + +export function isPasteShortcut(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "v" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +export async function cutCellSelection( + editor: Editor, + selection: CellSelection, +): Promise { + const copied = await copyCellSelection(editor, selection); + if (copied) { + editor.deleteSelection(); + } +} + +export async function copyCellSelection( + editor: Editor, + selection: CellSelection, +): Promise { + const block = editor.getBlock(selection.blockId); + if (!block) return false; + + const cellData: string[][] = []; + for (const rowCells of resolveCellSelectionMatrix(block, selection)) { + const row: string[] = []; + for (const cellCoord of rowCells) { + const cell = block.tableCell(cellCoord.row, cellCoord.col); + row.push(cell?.textContent() ?? ""); + } + cellData.push(row); + } + + const tabSeparated = cellData.map((row) => row.join("\t")).join("\n"); + const penCells = JSON.stringify({ + cells: cellData, + rows: cellData.length, + cols: Math.max(...cellData.map((row) => row.length), 0), + }); + const encodedPenCells = encodeURIComponent(penCells); + + const htmlRows = cellData.map((row) => + `${row.map((c) => `${escapeHtml(c)}`).join("")}`, + ).join(""); + const html = `${htmlRows}
          `; + const clipboard = globalThis.navigator?.clipboard; + if (!clipboard) { + return false; + } + + if ( + typeof ClipboardItem !== "undefined" && + typeof clipboard.write === "function" + ) { + try { + await clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([tabSeparated], { type: "text/plain" }), + "text/html": new Blob( + [`${html}`], + { type: "text/html" }, + ), + }), + ]); + return true; + } catch { + // Fall through to plain-text clipboard writes below. + } + } + + if (typeof clipboard.writeText === "function") { + try { + await clipboard.writeText(tabSeparated); + return true; + } catch { + return false; + } + } + + return false; +} + +export function pasteCellSelection(editor: Editor, selection: CellSelection): void { + navigator.clipboard.read().then((items) => { + for (const item of items) { + if (item.types.includes("text/html")) { + item.getType("text/html").then((blob) => { + blob.text().then((html) => { + const penCellsMatch = html.match(/data-pen-cells=(['"])(.*?)\1/); + if (penCellsMatch) { + try { + const parsed = parseEncodedCellPayload(penCellsMatch[2]); + applyPastedCells(editor, selection, parsed.cells); + return; + } catch { + // Fall through to plain-text clipboard reads below. + } + } + pasteFromPlainText(editor, selection); + }); + }); + return; + } + } + pasteFromPlainText(editor, selection); + }).catch(() => { + pasteFromPlainText(editor, selection); + }); +} + +function pasteFromPlainText(editor: Editor, selection: CellSelection): void { + navigator.clipboard.readText().then((text) => { + const cells = text.split("\n").map((row) => row.split("\t")); + applyPastedCells(editor, selection, cells); + }).catch(() => { }); +} + +function applyPastedCells(editor: Editor, selection: CellSelection, cellData: string[][]): void { + const block = editor.getBlock(selection.blockId); + if (!block) return; + + const rowCount = selection.rowIds?.length ?? block.tableRowCount(); + const colCount = selection.columnIds?.length ?? block.tableColumnCount(); + const startRow = Math.min(selection.anchor.row, selection.head.row); + const startCol = Math.min(selection.anchor.col, selection.head.col); + + const ops: DocumentOp[] = []; + for (let r = 0; r < cellData.length; r++) { + const targetRow = startRow + r; + if (targetRow >= rowCount) break; + for (let c = 0; c < cellData[r].length; c++) { + const targetCol = startCol + c; + if (targetCol >= colCount) break; + const resolvedCoord = resolveCellSelectionCoord(block, selection, { + row: targetRow, + col: targetCol, + }); + if (!resolvedCoord) continue; + const cell = block.tableCell(resolvedCoord.row, resolvedCoord.col); + if (!cell) continue; + const existingLen = cell.textContent().length; + if (existingLen > 0) { + ops.push({ + type: "delete-table-cell-text", + blockId: selection.blockId, + row: resolvedCoord.row, + col: resolvedCoord.col, + offset: 0, + length: existingLen, + }); + } + const pasteText = cellData[r][c]; + if (pasteText.length > 0) { + ops.push({ + type: "insert-table-cell-text", + blockId: selection.blockId, + row: resolvedCoord.row, + col: resolvedCoord.col, + offset: 0, + text: pasteText, + }); + } + } + } + + if (ops.length > 0) { + editor.apply(ops, { origin: "user" }); + } +} + +function parseEncodedCellPayload(raw: string): { cells: string[][] } { + try { + return JSON.parse(decodeURIComponent(raw)) as { cells: string[][] }; + } catch { + return JSON.parse(raw) as { cells: string[][] }; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/packages/rendering/react/src/utils/tableCellNavigation.ts b/packages/rendering/dom/src/utils/tableCellNavigation.ts similarity index 67% rename from packages/rendering/react/src/utils/tableCellNavigation.ts rename to packages/rendering/dom/src/utils/tableCellNavigation.ts index 3fd7c65..1d94c96 100644 --- a/packages/rendering/react/src/utils/tableCellNavigation.ts +++ b/packages/rendering/dom/src/utils/tableCellNavigation.ts @@ -1,3 +1,9 @@ +import { + copyCellSelection, + cutCellSelection, + isPasteShortcut, + pasteCellSelection, +} from "./tableCellClipboard"; import type { CellSelection, DocumentOp, Editor } from "@pen/types"; import { hasIndexedCellSelectionMetadata, @@ -41,7 +47,10 @@ export function handleTableCellSelectionKeyDown(options: { } const cellKeyDownSlot = editor.internals.getSlot("database:cell-keydown") as - | ((event: KeyboardEvent, context: { blockId: string; row: number; col: number; root: HTMLElement }) => boolean) + | (( + event: KeyboardEvent, + context: { blockId: string; row: number; col: number; root: HTMLElement }, + ) => boolean) | undefined; const slotCoord = resolveCellSelectionCoord(block, selection, { row: head.row, @@ -113,7 +122,7 @@ export function handleTableCellSelectionKeyDown(options: { if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) { event.preventDefault(); - editor.deleteSelection(); + editor.deleteSelection({ origin: "user" }); return true; } @@ -151,7 +160,7 @@ export function handleTableCellSelectionKeyDown(options: { const cellCoord = head; clearCellContent(editor, selection, blockId, cellCoord.row, cellCoord.col); activateCellEditing(editor, fieldEditor, blockId, selection, cellCoord.row, cellCoord.col, root); - insertCharInActiveCell(fieldEditor, editor, selection, blockId, cellCoord.row, cellCoord.col, event.key); + insertCharInActiveCell(editor, selection, blockId, cellCoord.row, cellCoord.col, event.key); return true; } @@ -327,7 +336,6 @@ function clearCellContent( } function insertCharInActiveCell( - _fieldEditor: FieldEditorTableNavigationController, editor: Editor, selection: CellSelection, blockId: string, @@ -431,190 +439,3 @@ function isCutShortcut(event: KeyboardEvent): boolean { (event.metaKey || event.ctrlKey) ); } - -function isPasteShortcut(event: KeyboardEvent): boolean { - return ( - event.key.toLowerCase() === "v" && - !event.shiftKey && - !event.altKey && - (event.metaKey || event.ctrlKey) - ); -} - -async function cutCellSelection( - editor: Editor, - selection: CellSelection, -): Promise { - const copied = await copyCellSelection(editor, selection); - if (copied) { - editor.deleteSelection(); - } -} - -async function copyCellSelection( - editor: Editor, - selection: CellSelection, -): Promise { - const block = editor.getBlock(selection.blockId); - if (!block) return false; - - const cellData: string[][] = []; - for (const rowCells of resolveCellSelectionMatrix(block, selection)) { - const row: string[] = []; - for (const cellCoord of rowCells) { - const cell = block.tableCell(cellCoord.row, cellCoord.col); - row.push(cell?.textContent() ?? ""); - } - cellData.push(row); - } - - const tabSeparated = cellData.map((row) => row.join("\t")).join("\n"); - const penCells = JSON.stringify({ - cells: cellData, - rows: cellData.length, - cols: Math.max(...cellData.map((row) => row.length), 0), - }); - const encodedPenCells = encodeURIComponent(penCells); - - const htmlRows = cellData.map((row) => - `${row.map((c) => `${escapeHtml(c)}`).join("")}`, - ).join(""); - const html = `${htmlRows}
          `; - const clipboard = globalThis.navigator?.clipboard; - if (!clipboard) { - return false; - } - - if ( - typeof ClipboardItem !== "undefined" && - typeof clipboard.write === "function" - ) { - try { - await clipboard.write([ - new ClipboardItem({ - "text/plain": new Blob([tabSeparated], { type: "text/plain" }), - "text/html": new Blob( - [`${html}`], - { type: "text/html" }, - ), - }), - ]); - return true; - } catch { - // Fall through to plain-text clipboard writes below. - } - } - - if (typeof clipboard.writeText === "function") { - try { - await clipboard.writeText(tabSeparated); - return true; - } catch { - return false; - } - } - - return false; -} - -function pasteCellSelection(editor: Editor, selection: CellSelection): void { - navigator.clipboard.read().then((items) => { - for (const item of items) { - if (item.types.includes("text/html")) { - item.getType("text/html").then((blob) => { - blob.text().then((html) => { - const penCellsMatch = html.match(/data-pen-cells=(['"])(.*?)\1/); - if (penCellsMatch) { - try { - const parsed = parseEncodedCellPayload(penCellsMatch[2]); - applyPastedCells(editor, selection, parsed.cells); - return; - } catch { /* fall through to plain text */ } - } - pasteFromPlainText(editor, selection); - }); - }); - return; - } - } - pasteFromPlainText(editor, selection); - }).catch(() => { - pasteFromPlainText(editor, selection); - }); -} - -function pasteFromPlainText(editor: Editor, selection: CellSelection): void { - navigator.clipboard.readText().then((text) => { - const cells = text.split("\n").map((row) => row.split("\t")); - applyPastedCells(editor, selection, cells); - }).catch(() => { }); -} - -function applyPastedCells(editor: Editor, selection: CellSelection, cellData: string[][]): void { - const block = editor.getBlock(selection.blockId); - if (!block) return; - - const rowCount = selection.rowIds?.length ?? block.tableRowCount(); - const colCount = selection.columnIds?.length ?? block.tableColumnCount(); - const startRow = Math.min(selection.anchor.row, selection.head.row); - const startCol = Math.min(selection.anchor.col, selection.head.col); - - const ops: DocumentOp[] = []; - for (let r = 0; r < cellData.length; r++) { - const targetRow = startRow + r; - if (targetRow >= rowCount) break; - for (let c = 0; c < cellData[r].length; c++) { - const targetCol = startCol + c; - if (targetCol >= colCount) break; - const resolvedCoord = resolveCellSelectionCoord(block, selection, { - row: targetRow, - col: targetCol, - }); - if (!resolvedCoord) continue; - const cell = block.tableCell(resolvedCoord.row, resolvedCoord.col); - if (!cell) continue; - const existingLen = cell.textContent().length; - if (existingLen > 0) { - ops.push({ - type: "delete-table-cell-text", - blockId: selection.blockId, - row: resolvedCoord.row, - col: resolvedCoord.col, - offset: 0, - length: existingLen, - }); - } - const pasteText = cellData[r][c]; - if (pasteText.length > 0) { - ops.push({ - type: "insert-table-cell-text", - blockId: selection.blockId, - row: resolvedCoord.row, - col: resolvedCoord.col, - offset: 0, - text: pasteText, - }); - } - } - } - - if (ops.length > 0) { - editor.apply(ops, { origin: "user" }); - } -} - -function parseEncodedCellPayload(raw: string): { cells: string[][] } { - try { - return JSON.parse(decodeURIComponent(raw)) as { cells: string[][] }; - } catch { - return JSON.parse(raw) as { cells: string[][] }; - } -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} diff --git a/packages/rendering/dom/src/utils/textEntryTarget.ts b/packages/rendering/dom/src/utils/textEntryTarget.ts new file mode 100644 index 0000000..09f5b98 --- /dev/null +++ b/packages/rendering/dom/src/utils/textEntryTarget.ts @@ -0,0 +1,233 @@ +import { DATA_ATTRS } from "./dataAttributes"; + +const TEXTBOX_ROLE_SELECTOR = '[role~="textbox"]'; +const FIELD_EDITOR_SURFACE_SELECTOR = `[${DATA_ATTRS.fieldEditorSurface}]`; +const ACTIVE_FIELD_EDITOR_SURFACE_SELECTOR = `[${DATA_ATTRS.fieldEditorActiveSurface}]`; + +type KeyboardRoutingSelection = + | null + | undefined + | { + type: string; + isCollapsed?: boolean; + isMultiBlock?: boolean; + blockIds?: readonly string[]; + }; + +type EditorKeyboardRoutingOptions = { + root: HTMLElement; + event: KeyboardEvent; + selection: KeyboardRoutingSelection; + hasMappedDomSelection?: () => boolean; + handleCollapsedTextSelection?: boolean; +}; + +export function isNativeTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target instanceof HTMLInputElement) { + return isTextEntryInput(target); + } + + return ( + target.isContentEditable || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.closest(TEXTBOX_ROLE_SELECTOR) !== null + ); +} + +export function isFieldEditorTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + const element = getClosestElement(target); + return element + ? element.closest(FIELD_EDITOR_SURFACE_SELECTOR) !== null + : false; +} + +export function isActiveFieldEditorTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + const element = getClosestElement(target); + return element + ? element.closest(ACTIVE_FIELD_EDITOR_SURFACE_SELECTOR) !== null + : false; +} + +export function isTextEntryTarget( + target: EventTarget | null, +): target is HTMLElement { + return ( + isNativeTextEntryTarget(target) || isFieldEditorTextEntryTarget(target) + ); +} + +export function isFieldEditorTextEditingKey(event: KeyboardEvent): boolean { + if (event.metaKey || event.ctrlKey || event.altKey || event.isComposing) { + return false; + } + + return ( + event.key.length === 1 || + event.key === "Enter" || + event.key === "Backspace" || + event.key === "Delete" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" || + event.key === "Home" || + event.key === "End" + ); +} + +export function shouldHandleEditorKeyboardEvent({ + root, + event, + selection, + hasMappedDomSelection, + handleCollapsedTextSelection = false, +}: EditorKeyboardRoutingOptions): boolean { + const targetRoot = getClosestEditorRoot(event.target); + if (targetRoot && targetRoot !== root) { + return false; + } + + if ( + isFieldEditorTextEditingKey(event) && + isActiveFieldEditorTextEntryTarget(event.target) + ) { + return isSelectionThatOverridesActiveTextEditingKey(selection); + } + + if ( + isNativeTextEntryTarget(event.target) && + !isFieldEditorTextEntryTarget(event.target) && + !root.contains(event.target) + ) { + return false; + } + + const activeElement = root.ownerDocument?.activeElement; + const activeRoot = getClosestEditorRoot(activeElement); + if (activeRoot && activeRoot !== root) { + return false; + } + + if (activeElement instanceof Node && root.contains(activeElement)) { + if (isFieldEditorTextEntryTarget(activeElement)) { + if (event.key === "Escape" || isCollapsedSelectAll(event)) { + return true; + } + + return isDocumentSelection(selection, handleCollapsedTextSelection); + } + + if (isNativeTextEntryTarget(activeElement)) { + return false; + } + + return true; + } + + if ( + activeElement instanceof Node && + !root.contains(activeElement) && + isNativeTextEntryTarget(activeElement) + ) { + return false; + } + + if (hasMappedDomSelection?.()) { + return true; + } + + if (isDocumentShortcut(event)) { + return isDocumentSelection(selection, true); + } + + return isDocumentSelection(selection, handleCollapsedTextSelection); +} + +export function getClosestEditorRoot( + target: EventTarget | null, +): HTMLElement | null { + const element = getClosestElement(target); + return element?.closest(`[${DATA_ATTRS.editorRoot}]`) as HTMLElement | null; +} + +function getClosestElement(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Node)) { + return null; + } + + return target instanceof HTMLElement ? target : target.parentElement; +} + +function isDocumentSelection( + selection: KeyboardRoutingSelection, + handleCollapsedTextSelection: boolean, +): boolean { + if (selection?.type === "cell") { + return true; + } + + if (selection?.type === "block") { + return (selection.blockIds?.length ?? 0) > 0; + } + + if (selection?.type === "text") { + return ( + handleCollapsedTextSelection || + selection.isMultiBlock === true || + selection.isCollapsed !== true + ); + } + + return false; +} + +function isSelectionThatOverridesActiveTextEditingKey( + selection: KeyboardRoutingSelection, +): boolean { + return ( + selection?.type === "cell" || + (selection?.type === "text" && selection.isMultiBlock === true) + ); +} + +function isCollapsedSelectAll(event: KeyboardEvent): boolean { + return ( + event.key.toLowerCase() === "a" && + !event.shiftKey && + !event.altKey && + (event.metaKey || event.ctrlKey) + ); +} + +function isDocumentShortcut(event: KeyboardEvent): boolean { + const key = event.key.toLowerCase(); + return ( + !event.altKey && + (event.metaKey || event.ctrlKey) && + (key === "a" || key === "z") + ); +} + +function isTextEntryInput(input: HTMLInputElement): boolean { + return !( + input.type === "checkbox" || + input.type === "radio" || + input.type === "button" || + input.type === "submit" || + input.type === "reset" || + input.type === "range" || + input.type === "color" || + input.type === "file" + ); +} diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx new file mode 100644 index 0000000..63a7fb6 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.01.test.tsx @@ -0,0 +1,400 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("renders bottom-chat markdown as schema blocks while streaming", async () => { + const releaseFinalDelta = createDeferred(); + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "# Story\n\nOnce upon " }; + await releaseFinalDelta.promise; + yield { type: "text-delta" as const, delta: "a time" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + let session: + | ReturnType["startSession"]> + | null = null; + let generationPromise: Promise | null = null; + + await act(async () => { + session = controller!.startSession({ + surface: "bottom-chat", + target: "document", + }); + generationPromise = controller!.runSessionPrompt( + session!.id, + "Write a short story", + { target: "document" }, + ); + await waitForCondition(() => { + const heading = container.querySelector("h1[data-block-type='heading']"); + const text = (container.textContent ?? "").replace(/\u200B/g, ""); + return heading?.textContent?.includes("Story") === true && text.includes("Once upon"); + }); + }); + + const heading = container.querySelector("h1[data-block-type='heading']"); + expect(heading?.textContent).toContain("Story"); + expect((container.textContent ?? "").replace(/\u200B/g, "")).toContain("Once upon"); + + await act(async () => { + releaseFinalDelta.resolve(); + await generationPromise; + }); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + it("exposes AI sessions through React hooks", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + function SessionProbe() { + const sessions = useAISessions(editor); + const activeSession = useActiveAISession(editor); + const actions = useAIActions(editor); + + return ( +
          + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + let sessionId = ""; + await act(async () => { + const session = controller?.startSession({ + surface: "inline-edit", + target: "selection", + }); + if (session) { + sessionId = session.id; + await controller?.runSessionPrompt(session.id, "Rewrite the selection"); + } + }); + + await act(async () => { + const controllerAny = controller as any; + controllerAny?._recordSessionFastApplyMetrics(sessionId, { + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + }); + await Promise.resolve(); + }); + + const probe = container.querySelector("[data-session-count]"); + expect(probe?.getAttribute("data-session-count")).toBe("1"); + expect(probe?.getAttribute("data-active-session-id")).toBeTruthy(); + expect(probe?.getAttribute("data-session-action-ready")).toBe(""); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx new file mode 100644 index 0000000..d38d353 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.02.test.tsx @@ -0,0 +1,437 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("exposes AI debug logs through a React hook", async () => { + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + function DebugProbe() { + const debugLog = useAIDebugLog(editor); + + return ( +
          + ); + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + const session = controller?.startSession({ + surface: "inline-edit", + target: "selection", + }); + if (session) { + await controller?.runSessionPrompt(session.id, "Rewrite the selection"); + } + }); + + const probe = container.querySelector("[data-entry-count]"); + expect(Number(probe?.getAttribute("data-entry-count"))).toBeGreaterThan(0); + expect(probe?.getAttribute("data-active-generation-id")).toBeTruthy(); + expect(probe?.getAttribute("data-aggregate-fast-apply-attempt-count")).toBe("1"); + expect(probe?.getAttribute("data-aggregate-fast-apply-native-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-attempt-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-native-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-scoped-count")).toBe("0"); + expect(probe?.getAttribute("data-fast-apply-plain-count")).toBe("0"); + expect(probe?.getAttribute("data-fast-apply-failed-count")).toBe("0"); + expect(probe?.getAttribute("data-last-entry-label")).toBe("Generation finished"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("reads fast-apply metrics for a requested session in the debug hook", async () => { + const editor = createEditor({ + extensions: [aiExtension({})], + }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + + function DebugProbe(props: { sessionId: string }) { + const debugLog = useAIDebugLog(editor, { sessionId: props.sessionId }); + + return ( +
          + ); + } + + const bottomChatSession = controller!.startSession({ + surface: "bottom-chat", + target: "document", + }); + const inlineSession = controller!.startSession({ + surface: "inline-edit", + target: "selection", + }); + expect(controller!.getState().activeSessionId).toBe(inlineSession.id); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + const controllerAny = controller as any; + controllerAny?._recordSessionFastApplyMetrics(bottomChatSession.id, { + attempted: true, + succeeded: true, + executionPath: "native-fast-apply", + }); + controllerAny?._recordSessionFastApplyMetrics(bottomChatSession.id, { + attempted: true, + succeeded: true, + executionPath: "scoped-replacement", + }); + root.render( + + + , + ); + await Promise.resolve(); + }); + + const probe = container.querySelector( + "[data-fast-apply-session-id]", + ) as HTMLElement | null; + expect(probe?.getAttribute("data-fast-apply-session-id")).toBe( + bottomChatSession.id, + ); + expect(probe?.getAttribute("data-aggregate-fast-apply-attempt-count")).toBe("2"); + expect(probe?.getAttribute("data-aggregate-fast-apply-native-count")).toBe("1"); + expect(probe?.getAttribute("data-fast-apply-attempt-count")).toBe("2"); + expect(probe?.getAttribute("data-fast-apply-native-count")).toBe("1"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + editor.destroy(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx new file mode 100644 index 0000000..7e5fccf --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.03.test.tsx @@ -0,0 +1,393 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = + window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = + window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => {}, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = + window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = + window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => {}, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = + editor.internals.getSlot( + "document-ops:toolRuntime", + ) ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("renders an inline AI session from the selection toolbar", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { + type: "text-delta" as const, + delta: "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange({ blockId, offset: 6 }, { blockId, offset: 11 }); + const controller = getAIController(editor); + expect(controller).toBeTruthy(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).toBeTruthy(); + expect(trigger?.disabled).toBe(false); + expect( + container.querySelector("[data-pen-selection-toolbar-content]"), + ).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInput = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + expect(inlineSessionInput).toBeTruthy(); + expect(document.activeElement).toBe(inlineSessionInput); + expect( + container.querySelector("[data-pen-selection-toolbar-content]"), + ).toBeNull(); + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).not.toBeNull(); + expect( + container.querySelector( + "[data-pen-ai-inline-session-turn-actions]", + ), + ).toBeNull(); + + await act(async () => { + const activeSessionId = + controller?.getState().activeSessionId ?? null; + if (activeSessionId) { + await controller?.runSessionPrompt( + activeSessionId, + "Rewrite this", + { + target: "selection", + }, + ); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const sessionId = controller?.getState().activeSessionId ?? null; + const sessionTurns = controller?.getState().sessions[0]?.turns ?? []; + expect(sessionTurns).toHaveLength(1); + expect( + container.querySelector( + "[data-pen-ai-inline-session-turn-actions]", + ), + ).not.toBeNull(); + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).toBeNull(); + + await act(async () => { + if (sessionId && sessionTurns[0]) { + controller?.acceptSessionTurn(sessionId, sessionTurns[0].id); + } + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx new file mode 100644 index 0000000..5fcde04 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.04.test.tsx @@ -0,0 +1,459 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("keeps the inline prompt focused after submitting a prompt", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 180, + width: 80, + height: 20, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInput = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + const inlineSessionForm = container.querySelector( + "[data-pen-ai-inline-session-form]", + ) as HTMLFormElement | null; + expect(inlineSessionInput).not.toBeNull(); + expect(inlineSessionForm).not.toBeNull(); + + await act(async () => { + inlineSessionInput?.focus(); + controller.updateContextualPromptDraft(session!.id, "Rewrite this"); + for (let tick = 0; tick < 2; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + inlineSessionForm?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInputAfterSubmit = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + expect(inlineSessionInputAfterSubmit).not.toBeNull(); + expect(document.activeElement).toBe(inlineSessionInputAfterSubmit); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + it("keeps the inline session open after a second submitted edit", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 180, + width: 80, + height: 20, + }); + let streamCount = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + streamCount += 1; + yield { + type: "text-delta" as const, + delta: streamCount === 1 ? "planet" : "galaxy", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor)!; + const session = controller.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + expect(session).not.toBeNull(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInput = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + const inlineSessionForm = container.querySelector( + "[data-pen-ai-inline-session-form]", + ) as HTMLFormElement | null; + expect(inlineSessionInput).not.toBeNull(); + expect(inlineSessionForm).not.toBeNull(); + + await act(async () => { + inlineSessionInput?.focus(); + controller.updateContextualPromptDraft(session!.id, "Rewrite this"); + for (let tick = 0; tick < 2; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + inlineSessionForm?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve(); + } + }); + + const inlineSessionInputAfterFirstSubmit = container.querySelector( + "[data-pen-ai-inline-session-input]", + ) as HTMLTextAreaElement | null; + expect(inlineSessionInputAfterFirstSubmit).not.toBeNull(); + + await act(async () => { + controller.updateContextualPromptDraft( + session!.id, + "Make it more whimsical", + ); + for (let tick = 0; tick < 2; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + inlineSessionForm?.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector("[data-pen-ai-inline-session-input]"), + ).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx new file mode 100644 index 0000000..663a50d --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.05.test.tsx @@ -0,0 +1,356 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("keeps the inline AI selection overlay visible from the captured session target", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).not.toBeNull(); + const initialAffectedRange = container.querySelector( + "[data-ai-affected-range]", + ) as HTMLElement | null; + expect(initialAffectedRange).not.toBeNull(); + expect(initialAffectedRange?.textContent).toBe("world"); + expect( + container.querySelector("[data-pen-ai-inline-session-target-hint]")?.textContent, + ).toBe("AI target is active"); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 0 }, + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector( + "[data-pen-ai-contextual-prompt-selection-overlay]", + ), + ).not.toBeNull(); + const preservedAffectedRange = container.querySelector( + "[data-ai-affected-range]", + ) as HTMLElement | null; + expect(preservedAffectedRange).not.toBeNull(); + expect(preservedAffectedRange?.textContent).toBe("world"); + expect( + container.querySelector("[data-pen-ai-inline-session-target-hint]")?.textContent, + ).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx new file mode 100644 index 0000000..a4d2019 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.06.test.tsx @@ -0,0 +1,363 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("keeps the inline AI session bound to its captured selection", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect( + container.querySelector("[data-pen-ai-inline-session-target-hint]")?.textContent, + ).toBe("AI target is active"); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const activeSessionId = controller?.getState().activeSessionId ?? null; + if (activeSessionId) { + await controller?.runSessionPrompt(activeSessionId, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const sessionId = controller?.getState().activeSessionId ?? null; + const sessionTurns = controller?.getState().sessions[0]?.turns ?? []; + expect(sessionTurns).toHaveLength(1); + + await act(async () => { + if (sessionId && sessionTurns[0]) { + controller?.acceptSessionTurn(sessionId, sessionTurns[0].id); + } + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(editor.getBlock(blockId)?.textContent({ resolved: true })).toBe( + "Hello planet", + ); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx new file mode 100644 index 0000000..6f20ff7 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.07.test.tsx @@ -0,0 +1,344 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("opens a fresh inline session for a new selection instead of reusing old review state", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "text-delta" as const, delta: "planet" }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world again" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const firstSession = controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + if (firstSession) { + await controller?.runSessionPrompt(firstSession.id, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const firstSessionId = controller?.getState().activeSessionId ?? null; + expect( + container.querySelector("[data-pen-ai-inline-session-turn-actions]"), + ).not.toBeNull(); + + await act(async () => { + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const activeSession = controller?.getActiveSession() ?? null; + expect(activeSession?.id).not.toBe(firstSessionId); + expect(activeSession?.turns).toHaveLength(0); + expect( + container.querySelector("[data-pen-ai-inline-session-turn-actions]"), + ).toBeNull(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx new file mode 100644 index 0000000..f927445 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.08.test.tsx @@ -0,0 +1,445 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("submits a new inline selection edit after keeping bottom-chat changes", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + let pass = 0; + const editor = createEditor({ + extensions: [ + aiExtension({ + contentFormat: { + blockGeneration: "markdown", + selectionRewrite: "text", + }, + model: { + async *stream() { + pass += 1; + yield { + type: "text-delta" as const, + delta: pass === 1 ? "Hello world" : "planet", + }; + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + + AI + + + + + + , + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const bottomChatSession = controller?.startSession({ + surface: "bottom-chat", + target: "document", + }); + if (bottomChatSession) { + await controller?.runSessionPrompt( + bottomChatSession.id, + "Write something in the document", + { target: "document" }, + ); + const keptTurnId = controller + ?.getSessions() + .find((session) => session.id === bottomChatSession.id) + ?.turns[0]?.id; + if (keptTurnId) { + controller?.acceptSessionTurn(bottomChatSession.id, keptTurnId); + } + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const blockId = editor.firstBlock()!.id; + await act(async () => { + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const trigger = container.querySelector( + "[data-pen-ai-selection-trigger]", + ) as HTMLButtonElement | null; + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.dispatchEvent( + new Event("pointerdown", { + bubbles: true, + cancelable: true, + }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + await act(async () => { + const activeSessionId = controller?.getState().activeSessionId ?? null; + if (activeSessionId) { + await controller?.runSessionPrompt(activeSessionId, "Rewrite this", { + target: "selection", + }); + } + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + }); + + const activeSession = controller?.getActiveSession() ?? null; + expect(activeSession?.surface).toBe("inline-edit"); + expect(activeSession?.turns).toHaveLength(1); + expect(activeSession?.turns[0]?.status).toBe("review"); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + it("renders a durable affected-range decoration while the inline session is visible", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + , + ); + await Promise.resolve(); + }); + + await act(async () => { + controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + const decorations = ( + controller as unknown as { + buildDecorations: () => Array<{ attributes?: Record }>; + } + ).buildDecorations(); + expect( + decorations.some( + (decoration) => decoration.attributes?.["data-ai-affected-range"] === "", + ), + ).toBe(true); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + +}); diff --git a/packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx b/packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx new file mode 100644 index 0000000..8c2a352 --- /dev/null +++ b/packages/rendering/react/src/__tests__/aiPrimitives.09.test.tsx @@ -0,0 +1,409 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { describe, expect, it } from "vitest"; +import { createRoot } from "react-dom/client"; +import { createEditor } from "@pen/core"; +import { defineExtension, type ToolRuntime } from "@pen/types"; +import { aiExtension, getAIController } from "@pen/ai"; +import { defaultPreset } from "@pen/preset-default"; +import { + Pen, + useAIActions, + useAISessions, + useActiveAISession, + useAIDebugLog, +} from "../index"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createKeyDownEvent( + key: string, + options: KeyboardEventInit = {}, +): KeyboardEvent { + return new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + ...options, + }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function withNavigatorPlatform(platform: string, run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: platform, + }); + try { + return run(); + } finally { + if (descriptor) { + Object.defineProperty(navigator, "platform", descriptor); + } + } +} + +function mockSelectionToolbarRect(rect: { + top: number; + left: number; + width: number; + height: number; +}) { + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + const rangeRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + } as DOMRect; + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => rangeRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }; +} + +function mockMutableSelectionToolbarRect(initialRect: { + top: number; + left: number; + width: number; + height: number; +}) { + const rect = { ...initialRect }; + const originalGetSelection = window.getSelection.bind(window); + const originalRequestAnimationFrame = window.requestAnimationFrame.bind(window); + const originalCancelAnimationFrame = window.cancelAnimationFrame.bind(window); + + Object.defineProperty(window, "getSelection", { + configurable: true, + value: () => ({ + rangeCount: 1, + getRangeAt: () => ({ + getBoundingClientRect: () => + ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON() { + return this; + }, + }) as DOMRect, + }), + }), + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: () => { }, + }); + + return { + rect, + restore: () => { + Object.defineProperty(window, "getSelection", { + configurable: true, + value: originalGetSelection, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: originalCancelAnimationFrame, + }); + }, + }; +} + +async function waitForAttributeValue( + readValue: () => string | null | undefined, + expectedValue: string, + maxTicks = 12, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (readValue() === expectedValue) { + return; + } + await Promise.resolve(); + } +} + +async function waitForCondition( + check: () => boolean, + maxTicks = 20, +): Promise { + for (let tick = 0; tick < maxTicks; tick += 1) { + if (check()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function testStreamingToolExtension() { + let toolRuntime: ToolRuntime | null = null; + + return defineExtension({ + name: "test-streaming-tool", + dependencies: ["document-ops"], + activateClient: async ({ editor }) => { + toolRuntime = editor.internals.getSlot("document-ops:toolRuntime") ?? null; + toolRuntime?.registerTool({ + name: "test_search", + description: "Test streaming search tool", + inputSchema: { + type: "object", + required: ["query"], + properties: { + query: { type: "string" }, + }, + }, + async *handler(input: unknown) { + const { query } = input as { query: string }; + yield `searching:${query}`; + yield { matches: 2, query }; + }, + }); + }, + deactivateClient: async () => { + toolRuntime?.unregisterTool("test_search"); + toolRuntime = null; + }, + }); +} + +describe("@pen/react AI primitives", () => { + it("does not reopen raw inline UI history through keyboard shortcuts without a turn boundary", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 160, + width: 120, + height: 18, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 6 }, + { blockId, offset: 11 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + + + + , + ); + await Promise.resolve(); + }); + + await act(async () => { + const session = controller?.openContextualPrompt({ + surface: "inline-edit", + target: "selection", + }); + if (session) { + controller?.suspendInlineSession(session.id); + } + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(container.querySelector("[data-pen-ai-inline-session-input]")).toBeNull(); + + await act(async () => { + document.dispatchEvent(createKeyDownEvent("z", { ctrlKey: true })); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(container.querySelector("[data-pen-ai-inline-session-input]")).toBeNull(); + + await act(async () => { + document.dispatchEvent( + createKeyDownEvent("z", { ctrlKey: true, shiftKey: true }), + ); + for (let tick = 0; tick < 4; tick += 1) { + await Promise.resolve(); + } + }); + + expect(container.querySelector("[data-pen-ai-inline-session-input]")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + restoreSelectionRect(); + container.remove(); + }); + + it("ignores inline history shortcuts from external textareas", async () => { + const restoreSelectionRect = mockSelectionToolbarRect({ + top: 120, + left: 180, + width: 80, + height: 20, + }); + const editor = createEditor({ + extensions: [ + aiExtension({ + model: { + async *stream() { + yield { type: "done" as const }; + }, + }, + }), + ], + }); + const blockId = editor.firstBlock()!.id; + editor.apply( + [{ type: "insert-text", blockId, offset: 0, text: "Hello world" }], + { origin: "system" }, + ); + editor.selectTextRange( + { blockId, offset: 0 }, + { blockId, offset: 5 }, + ); + const controller = getAIController(editor); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + +