diff --git a/.changeset/fix-jsx-runtime-nested-siblings.md b/.changeset/fix-jsx-runtime-nested-siblings.md new file mode 100644 index 0000000000..79ad9be201 --- /dev/null +++ b/.changeset/fix-jsx-runtime-nested-siblings.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Fix JSX runtime to properly render nested sibling elements by spreading children arrays into DOMOutputSpec diff --git a/.changeset/new-pots-admire.md b/.changeset/new-pots-admire.md new file mode 100644 index 0000000000..09e7097fac --- /dev/null +++ b/.changeset/new-pots-admire.md @@ -0,0 +1,5 @@ +--- +"@tiptap/ai-toolkit": minor +--- + +Add the `AiInsertReveal` extension (`@tiptap/ai-toolkit/streaming-reveal`) to fade in text as the AI streams it into a collaborative document. diff --git a/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts new file mode 100644 index 0000000000..48d9a0ea56 --- /dev/null +++ b/packages/ai-toolkit/__tests__/streaming-reveal.spec.ts @@ -0,0 +1,509 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/core' +import { Collaboration } from '@tiptap/extension-collaboration' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +import { AiInsertReveal } from '../src/streaming-reveal.js' + +const AI_CLIENT_ID = 111111 +const HUMAN_CLIENT_ID = 222222 + +/** The awareness payload the Tiptap AI server publishes while it streams. */ +const AI_USER = { name: 'AI', color: '#8B5CF6', aiInstanceId: 'ai-instance-1' } + +/** Stands in for a collab provider, exposing only the awareness surface we read. */ +function createProvider() { + const states = new Map>() + const listeners = new Set<() => void>() + + return { + awareness: { + getStates: () => states, + on: (_event: string, listener: () => void) => listeners.add(listener), + off: (_event: string, listener: () => void) => listeners.delete(listener), + }, + announce(clientId: number, user: Record) { + states.set(clientId, { user }) + listeners.forEach(listener => listener()) + }, + } +} + +/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */ +function createEditor(): Promise { + return new Promise(resolve => { + const editor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, AiInsertReveal.configure({ provider: createProvider() })], + content: { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }], + }, + onCreate: () => { + resolve(editor) + }, + }) + }) +} + +/** A remote peer that keeps its clientID across writes. */ +function createPeer(ydoc: Y.Doc, clientID: number): Y.Doc { + const peer = new Y.Doc() + peer.clientID = clientID + Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc)) + return peer +} + +/** Creates a collaborative editor seeded with one `Hello` paragraph, plus two remote peers. */ +function createCollabEditor(options?: { + durationMs?: number +}): Promise<{ editor: Editor; ydoc: Y.Doc; ai: Y.Doc; human: Y.Doc }> { + const ydoc = new Y.Doc() + const provider = createProvider() + provider.announce(AI_CLIENT_ID, AI_USER) + provider.announce(HUMAN_CLIENT_ID, { name: 'Someone else', color: '#0EA5E9' }) + + return new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal.configure({ + provider, + ...(options?.durationMs === undefined ? {} : { durationMs: options.durationMs }), + }), + ], + onCreate: ({ editor }) => { + // Collaboration ignores the `content` prop (the empty Y.Doc wins when the + // y-sync plugin binds), so seed the shared doc with a local edit instead. + editor.commands.setContent('

Hello

') + resolve({ + editor, + ydoc, + ai: createPeer(ydoc, AI_CLIENT_ID), + human: createPeer(ydoc, HUMAN_CLIENT_ID), + }) + }, + }) + }) +} + +/** Applies an insert from `peer`, so it lands as a remote transaction authored by it. */ +function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): void { + Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer))) + const paragraph = peer.getXmlFragment('default').get(0) as Y.XmlElement + const xmlText = paragraph.get(0) as Y.XmlText + xmlText.insert(index, text) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc))) +} + +/** Applies a whole new block from `peer`, the way the AI writes a fresh paragraph. */ +function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void { + Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer))) + const fragment = peer.getXmlFragment('default') + const block = new Y.XmlElement('paragraph') + const blockText = new Y.XmlText() + block.insert(0, [blockText]) + fragment.insert(0, [block]) + if (text.length > 0) blockText.insert(0, text) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc))) +} + +/** Collects the extension's current reveal decorations, resolved against `state`. */ +function revealDecorations( + editor: Editor, + state: unknown = editor.state, +): Array<{ from: number; to: number; style: string }> { + for (const plugin of editor.state.plugins) { + const set = (plugin as any).props?.decorations?.call(plugin, state) + const found = (set?.find?.() ?? []).filter( + (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', + ) + if (found.length > 0) { + return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' })) + } + } + return [] +} + +describe('AiInsertReveal', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('is a named Tiptap extension', () => { + expect(AiInsertReveal.name).toBe('aiInsertReveal') + }) + + it('warns and degrades to a no-op when no collaboration y-sync plugin is present', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const editor = await createEditor() + + expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true) + // Without a y-sync plugin the decorations source resolves to nothing, so the + // editor renders normally rather than throwing. + expect(editor.getText()).toBe('Hello') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Collaboration extension')) + + warn.mockRestore() + editor.destroy() + }) + + it('applies configured className and durationMs', async () => { + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + AiInsertReveal.configure({ + className: 'custom-reveal', + durationMs: 300, + provider: createProvider(), + }), + ], + onCreate: () => resolve(created), + }) + }) + + const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal') + expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 }) + + editor.destroy() + }) + + it('reveals a remote insert as a decoration over exactly the inserted run', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsert(ai, ydoc, 5, ' WORLD') + + expect(editor.getText()).toBe('Hello WORLD') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length) + // The age-seeded animation-delay is present so the fade survives re-renders. + expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/) + + editor.destroy() + }) + + it('reveals a block that arrives as a whole new node', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsertBlock(ai, ydoc, 'A brand new title') + + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe('A brand new title'.length) + + editor.destroy() + }) + + it('reveals text streamed into a block that was created empty', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsertBlock(ai, ydoc, '') + remoteInsert(ai, ydoc, 0, 'Streamed title') + + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe('Streamed title'.length) + + editor.destroy() + }) + + it('merges touching runs that share an animation offset into one decoration', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + // Only the clock is faked: the editor is created through a real `setTimeout`. + vi.useFakeTimers({ toFake: ['Date'] }) + + remoteInsert(ai, ydoc, 5, ' one') + remoteInsert(ai, ydoc, 9, ' two') + + expect(editor.getText()).toBe('Hello one two') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(' one two'.length) + + editor.destroy() + }) + + it('reveals an insert at the very end of the document', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsert(ai, ydoc, 5, '!') + + expect(editor.getText()).toBe('Hello!') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(1) + expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size) + + editor.destroy() + }) + + it('clamps a decoration that resolves past the end of the document', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsert(ai, ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + // Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc. + const shortDoc = editor.schema.nodeFromJSON({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }], + }) + const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc }) + + expect(decorations).toHaveLength(1) + expect(decorations[0].to).toBe(shortDoc.content.size) + + editor.destroy() + }) + + it("does not reveal another collaborator's remote insert", async () => { + const { editor, ydoc, human } = await createCollabEditor() + + remoteInsert(human, ydoc, 5, ' WORLD') + + // The insert lands like any remote edit, it just must not fade. + expect(editor.getText()).toBe('Hello WORLD') + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('reveals the AI while a collaborator types alongside it', async () => { + const { editor, ydoc, ai, human } = await createCollabEditor() + + remoteInsert(human, ydoc, 5, ' HUMAN') + remoteInsert(ai, ydoc, 11, ' AI') + + expect(editor.getText()).toBe('Hello HUMAN AI') + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(' AI'.length) + + editor.destroy() + }) + + it('reveals a run that arrived before awareness identified the AI', async () => { + const ydoc = new Y.Doc() + const provider = createProvider() + + const editor = await new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal.configure({ provider }), + ], + onCreate: ({ editor: created }) => { + created.commands.setContent('

Hello

') + resolve(created) + }, + }) + }) + + // The AI's first tokens can land before its awareness entry does. + remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(0) + + provider.announce(AI_CLIENT_ID, AI_USER) + + expect(revealDecorations(editor)).toHaveLength(1) + + editor.destroy() + }) + + it('reveals a multi-block structure the AI writes as one node', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + Y.applyUpdate(ai, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ai))) + const list = new Y.XmlElement('bulletList') + for (let item = 0; item < 3; item++) { + const listItem = new Y.XmlElement('listItem') + const paragraph = new Y.XmlElement('paragraph') + const text = new Y.XmlText() + paragraph.insert(0, [text]) + listItem.insert(0, [paragraph]) + list.insert(list.length, [listItem]) + text.insert(0, `Item ${item}`) + } + ai.getXmlFragment('default').insert(0, [list]) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ai, Y.encodeStateVector(ydoc))) + + // One structure, so the block cap must not mistake it for a document sync. + expect(revealDecorations(editor)).toHaveLength(3) + + editor.destroy() + }) + + it('reveals nothing when a whole AI-authored document arrives at once', async () => { + const ydoc = new Y.Doc() + const provider = createProvider() + provider.announce(AI_CLIENT_ID, AI_USER) + + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal.configure({ provider }), + ], + onCreate: () => resolve(created), + }) + }) + + // Joining a room hands over everything the AI wrote before, in one update. + const server = new Y.Doc() + server.clientID = AI_CLIENT_ID + const fragment = server.getXmlFragment('default') + for (let block = 0; block < 5; block++) { + const element = new Y.XmlElement('paragraph') + const text = new Y.XmlText() + element.insert(0, [text]) + fragment.insert(fragment.length, [element]) + text.insert(0, `Paragraph ${block} written earlier by the AI.`) + } + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(server)) + + expect(editor.getText().length).toBeGreaterThan(0) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('warns when the provider has no awareness', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, AiInsertReveal.configure({ provider: {} })], + onCreate: () => resolve(created), + }) + }) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no awareness')) + + warn.mockRestore() + editor.destroy() + }) + + it('warns when durationMs cannot hold a reveal', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const editor = await new Promise(resolve => { + const created = new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit, + AiInsertReveal.configure({ provider: createProvider(), durationMs: 0 }), + ], + onCreate: () => resolve(created), + }) + }) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('durationMs')) + + warn.mockRestore() + editor.destroy() + }) + + it('warns and reveals nothing when no provider is configured', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const ydoc = new Y.Doc() + + const editor = await new Promise(resolve => { + new Editor({ + element: document.createElement('div'), + extensions: [ + StarterKit.configure({ undoRedo: false }), + Collaboration.configure({ document: ydoc }), + AiInsertReveal, + ], + onCreate: ({ editor: created }) => { + created.commands.setContent('

Hello

') + resolve(created) + }, + }) + }) + + remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD') + + // Without awareness the AI cannot be identified, so it fails closed. + expect(editor.getText()).toBe('Hello WORLD') + expect(revealDecorations(editor)).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"provider" option is required')) + + warn.mockRestore() + editor.destroy() + }) + + it("does not reveal the local user's own typing", async () => { + const { editor } = await createCollabEditor() + + // A local transaction (transaction.local === true) must be ignored. + editor.commands.insertContentAt(6, 'X') + + expect(editor.getText()).toBe('HelloX') + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('drops the reveal once its duration has elapsed', async () => { + const { editor, ydoc, ai } = await createCollabEditor({ durationMs: 30 }) + vi.useFakeTimers({ toFake: ['Date'] }) + + remoteInsert(ai, ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + vi.setSystemTime(Date.now() + 60) + expect(revealDecorations(editor)).toHaveLength(0) + + editor.destroy() + }) + + it('clamps a run longer than the max reveal range to that many characters', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsert(ai, ydoc, 5, 'x'.repeat(401)) + + expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`) + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + // The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400). + expect(decorations[0].to - decorations[0].from).toBe(400) + + editor.destroy() + }) + + it('drops a run whose resolved span no longer matches its inserted length', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + + remoteInsert(ai, ydoc, 5, ' WORLD') + // 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale. + remoteInsert(ai, ydoc, 8, 'XYZ') + + const decorations = revealDecorations(editor) + expect(decorations).toHaveLength(1) + expect(decorations[0].to - decorations[0].from).toBe(3) + + editor.destroy() + }) + + it('tears down cleanly after a reveal without throwing', async () => { + const { editor, ydoc, ai } = await createCollabEditor() + remoteInsert(ai, ydoc, 5, ' WORLD') + expect(revealDecorations(editor)).toHaveLength(1) + + expect(() => editor.destroy()).not.toThrow() + }) +}) diff --git a/packages/ai-toolkit/package.json b/packages/ai-toolkit/package.json index 107c4947c6..2bc6ca18c0 100644 --- a/packages/ai-toolkit/package.json +++ b/packages/ai-toolkit/package.json @@ -39,6 +39,14 @@ }, "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./streaming-reveal": { + "types": { + "import": "./dist/streaming-reveal.d.ts", + "require": "./dist/streaming-reveal.d.cts" + }, + "import": "./dist/streaming-reveal.js", + "require": "./dist/streaming-reveal.cjs" } }, "scripts": { @@ -51,10 +59,22 @@ }, "devDependencies": { "@tiptap/core": "workspace:^", - "@tiptap/pm": "workspace:^" + "@tiptap/pm": "workspace:^", + "@tiptap/y-tiptap": "^3.0.7", + "yjs": "^13.6.23" }, "peerDependencies": { "@tiptap/core": "^3.0.1", - "@tiptap/pm": "^3.0.1" + "@tiptap/pm": "^3.0.1", + "@tiptap/y-tiptap": "^3.0.0", + "yjs": "^13.6.23" + }, + "peerDependenciesMeta": { + "@tiptap/y-tiptap": { + "optional": true + }, + "yjs": { + "optional": true + } } } diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts new file mode 100644 index 0000000000..1404418c0b --- /dev/null +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -0,0 +1,392 @@ +import { Extension } from '@tiptap/core' +import type { Node as PMNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { relativePositionToAbsolutePosition, ySyncPluginKey } from '@tiptap/y-tiptap' +import * as Y from 'yjs' + +/** + * Configuration for {@link AiInsertReveal}. + */ +export type AiInsertRevealOptions = { + /** CSS class on each revealed run; style it in your app to define the fade. */ + className: string + /** + * How long (ms) each run keeps its reveal decoration before it is dropped. + * Keep it at or above your CSS animation duration so the fade can finish. + */ + durationMs: number + /** + * The collaboration provider, e.g. `HocuspocusProvider` or `TiptapCloudProvider`. + * Its awareness is what identifies the AI, so nothing is revealed without it. + */ + provider: RevealProvider | null +} + +/** The awareness surface this extension reads from the provider. */ +export type RevealProvider = { + awareness?: { + getStates: () => Map | undefined> + on: (event: 'change', listener: () => void) => void + off: (event: 'change', listener: () => void) => void + } +} + +/** Caps the fade to a run's first N chars; the rest reveals instantly. */ +const MAX_REVEAL_RANGE = 400 + +/** ProseMirror compares decoration attrs by value, so an unrounded offset rebuilds the DOM. */ +const REVEAL_AGE_STEP = 100 + +/** Above this, an update is a document sync, such as joining a room, not live streaming. */ +const MAX_BLOCKS_PER_UPDATE = 2 + +/** Yjs relative positions so each run survives y-tiptap doc rebuilds. */ +type RevealEntry = { + start: Y.RelativePosition + end: Y.RelativePosition + at: number + /** Inserted char count; a resolved span that drifts from it is stale. */ + length: number + /** Author, resolved against awareness at render time rather than on arrival. */ + client: number +} + +/** Minimal shape of the y-sync plugin state we read. */ +type YSyncState = { + doc: Y.Doc + type: Y.XmlFragment + binding: { mapping: Map, PMNode | PMNode[]> } | null +} + +const aiInsertRevealKey = new PluginKey('aiInsertReveal') + +function resolveRevealRange( + ystate: YSyncState, + entry: RevealEntry, + now: number, + durationMs: number, + docSize: number, +): { from: number; to: number; age: number } | null { + const age = now - entry.at + if (age >= durationMs) return null + + const span = resolveSpan(ystate, entry, docSize) + return span === null ? null : { ...span, age } +} + +function resolveSpan( + ystate: YSyncState, + entry: RevealEntry, + docSize: number, +): { from: number; to: number } | null { + if (!ystate.binding) return null + + const from = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.start, + ystate.binding.mapping, + ) + const to = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.end, + ystate.binding.mapping, + ) + return from === null || to === null ? null : clampSpan(from, to, entry.length, docSize) +} + +/** Drops a span whose width drifted from the insert, so stale mappings never paint. */ +function clampSpan( + from: number, + to: number, + length: number, + docSize: number, +): { from: number; to: number } | null { + const lo = Math.min(from, to) + const hi = Math.max(from, to) + if (hi - lo !== length) return null + + const start = Math.max(0, lo) + const end = Math.min(docSize, hi, start + MAX_REVEAL_RANGE) + return start >= end ? null : { from: start, to: end } +} + +/** Merges touching runs with the same offset, so a stream paints a few spans, not one per token. */ +function mergeRuns( + ranges: Array<{ from: number; to: number; age: number }>, +): Array<{ from: number; to: number; delay: number }> { + const merged: Array<{ from: number; to: number; delay: number }> = [] + + for (const range of ranges) { + const delay = Math.round(range.age / REVEAL_AGE_STEP) * REVEAL_AGE_STEP + const last = merged[merged.length - 1] + + if (last !== undefined && last.delay === delay && last.to === range.from) { + last.to = range.to + } else { + merged.push({ from: range.from, to: range.to, delay }) + } + } + + return merged +} + +/** The Tiptap AI server tags the identity it publishes with an instance id. */ +function isAiUser(user: Record | undefined): boolean { + return Boolean(user?.aiInstanceId) +} + +/** Clients whose clock advanced in this transaction, i.e. who authored it. */ +function transactionAuthors(transaction: Y.Transaction): number[] { + const authors: number[] = [] + transaction.afterState.forEach((clock, client) => { + if ((transaction.beforeState.get(client) ?? 0) < clock) authors.push(client) + }) + return authors +} + +function collectInsertedRuns( + event: Y.YEvent>, + now: number, + client: number, +): RevealEntry[] { + const target = event.target + const runs: RevealEntry[] = [] + + if (target instanceof Y.XmlText) { + let index = 0 + for (const op of event.delta) { + const { advance, inserted } = scanDeltaOp(op) + if (inserted > 0) runs.push(makeRun(target, index, inserted, now, client)) + index += advance + } + return runs + } + + // Whole nodes arrive when the AI writes a heading or a fresh block, so their + // text never shows up as an insert into existing content. + for (const op of event.delta) { + if (!Array.isArray(op.insert)) continue + if (op.insert.length > MAX_BLOCKS_PER_UPDATE) continue + for (const node of op.insert) collectNodeText(node, now, client, runs) + } + return runs +} + +function collectNodeText(node: unknown, now: number, client: number, runs: RevealEntry[]): void { + if (node instanceof Y.XmlText) { + if (node.length > 0) runs.push(makeRun(node, 0, node.length, now, client)) + return + } + if (node instanceof Y.XmlElement) { + for (const child of node.toArray()) collectNodeText(child, now, client, runs) + } +} + +function scanDeltaOp(op: { retain?: number; insert?: unknown }): { + advance: number + inserted: number +} { + if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 } + if (typeof op.insert === 'string') + return { advance: op.insert.length, inserted: op.insert.length } + // A non-string insert (embed) advances one position but is not a revealed run. + return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } +} + +/** + * End anchor assoc < 0 so an appended token starts a new run, not extends it. + * At index 0 the start needs the same assoc, or it resolves to nothing. + */ +function makeRun( + target: Y.XmlText, + index: number, + length: number, + now: number, + client: number, +): RevealEntry { + return { + start: Y.createRelativePositionFromTypeIndex(target, index, index === 0 ? -1 : 0), + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + length, + client, + } +} + +/** + * Fades in text the AI streams into the document, using view-only decorations. + * It never mutates the document, so it is inert to accept/reject and persistence. + * Requires Collaboration, a provider, and CSS for `className` (default `ai-insert-reveal`). + */ +export const AiInsertReveal = Extension.create({ + name: 'aiInsertReveal', + + addOptions() { + return { + className: 'ai-insert-reveal', + durationMs: 550, + provider: null, + } + }, + + onCreate() { + const { provider, durationMs } = this.options + + if (!provider) { + console.warn( + '[tiptap warn]: The "provider" option is required for "AiInsertReveal" to tell the AI apart from other collaborators. Nothing is revealed without it.', + ) + } else if (!provider.awareness) { + console.warn( + '[tiptap warn]: The provider passed to "AiInsertReveal" has no awareness, so the AI cannot be identified. Nothing is revealed.', + ) + } + + if (durationMs <= 0) { + console.warn( + `[tiptap warn]: "AiInsertReveal" needs a positive "durationMs" to hold a reveal, got ${durationMs}. Nothing is revealed.`, + ) + } + }, + + addProseMirrorPlugins() { + const { className, durationMs, provider } = this.options + + // Runs in insertion (time) order, so expired entries are always a prefix. + const entries: RevealEntry[] = [] + + // Never pruned: the AI clears its awareness entry when it stops, and its first + // tokens can arrive before that entry lands. + const aiClients = new Set() + + const dropExpired = (now: number) => { + let firstActive = 0 + while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) { + firstActive += 1 + } + if (firstActive > 0) entries.splice(0, firstActive) + } + + return [ + new Plugin({ + key: aiInsertRevealKey, + + props: { + decorations: state => { + const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined + if (entries.length === 0 || !ystate?.binding) return null + + const now = Date.now() + const ranges = entries + .filter(entry => aiClients.has(entry.client)) + .map(entry => + resolveRevealRange(ystate, entry, now, durationMs, state.doc.content.size), + ) + .filter((range): range is NonNullable => range !== null) + + // y-tiptap rebuilds the whole doc per token, restarting the CSS + // animation; offset by the run's age to resume the fade instead. + const decorations = mergeRuns(ranges).map(range => + Decoration.inline(range.from, range.to, { + class: className, + style: `animation-delay: -${range.delay}ms`, + }), + ) + + return DecorationSet.create(state.doc, decorations) + }, + }, + + view: view => { + const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined + const fragment = initialState?.type ?? null + const awareness = provider?.awareness ?? null + + if (fragment === null) { + console.warn( + '[tiptap warn]: "AiInsertReveal" needs the Collaboration extension to see incoming text. Nothing is revealed.', + ) + } + + const trackAiClients = () => { + const known = aiClients.size + awareness + ?.getStates() + .forEach((state: Record | undefined, clientId: number) => { + if (isAiUser(state?.user)) aiClients.add(clientId) + }) + // Runs that arrived before the AI was identified can now be revealed. + if (aiClients.size > known) scheduleRerender() + } + + let raf: number | null = null + let pruneTimer: ReturnType | null = null + + const rerender = () => { + if (view.isDestroyed) return + // Empty transaction: re-runs `decorations` so new entries paint and expired ones drop. + view.dispatch(view.state.tr.setMeta('addToHistory', false)) + } + + // Coalesce bursts of tokens landing in the same frame into one render. + const scheduleRerender = () => { + if (raf !== null) return + raf = requestAnimationFrame(() => { + raf = null + rerender() + }) + } + + // After the stream pauses, one delayed render removes the last runs' + // decorations once their fades have completed. + const schedulePrune = () => { + if (pruneTimer !== null) clearTimeout(pruneTimer) + pruneTimer = setTimeout(() => { + pruneTimer = null + dropExpired(Date.now()) + rerender() + }, durationMs + 80) + } + + const onChange = ( + events: Array>>, + transaction: Y.Transaction, + ) => { + if (transaction.local) return + + // A batch mixing several authors cannot be attributed run by run. + const authors = transactionAuthors(transaction) + if (authors.length !== 1) return + + const now = Date.now() + dropExpired(now) + + const runs = events.flatMap(event => collectInsertedRuns(event, now, authors[0])) + if (runs.length === 0) return + + entries.push(...runs) + scheduleRerender() + schedulePrune() + } + + trackAiClients() + awareness?.on('change', trackAiClients) + fragment?.observeDeep(onChange) + + return { + destroy: () => { + awareness?.off('change', trackAiClients) + fragment?.unobserveDeep(onChange) + if (raf !== null) cancelAnimationFrame(raf) + if (pruneTimer !== null) clearTimeout(pruneTimer) + entries.length = 0 + }, + } + }, + }), + ] + }, +}) diff --git a/packages/ai-toolkit/tsup.config.ts b/packages/ai-toolkit/tsup.config.ts index 03b7c8d0b6..c3269074b0 100644 --- a/packages/ai-toolkit/tsup.config.ts +++ b/packages/ai-toolkit/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts'], + entry: { index: 'src/index.ts', 'streaming-reveal': 'src/streaming-reveal.ts' }, tsconfig: '../../tsconfig.build.json', outDir: 'dist', dts: true, diff --git a/packages/core/__tests__/jsx-runtime.spec.ts b/packages/core/__tests__/jsx-runtime.spec.ts index ec9631ef89..ef460aa298 100644 --- a/packages/core/__tests__/jsx-runtime.spec.ts +++ b/packages/core/__tests__/jsx-runtime.spec.ts @@ -1,22 +1,349 @@ import { describe, expect, it } from 'vitest' -import { jsx, jsxs } from '../src/jsx-runtime.js' +import { Fragment, h, jsx, jsxs } from '../src/jsx-runtime.js' -describe('JSX runtime', () => { - it('keeps text and element siblings separate', () => { - const element = jsx('strong', { children: 'text' }) +describe('JSX Runtime', () => { + describe('basic functionality', () => { + it('should render a simple element tag', () => { + const result = h('div', {}) - expect(jsxs('p', { children: ['Before ', element] })).toEqual([ - 'p', - {}, - 'Before ', - ['strong', {}, 'text'], - ]) + expect(result).toEqual(['div', {}]) + }) + + it('should render an element with attributes', () => { + const result = h('div', { class: 'container', id: 'main' }) + + expect(result).toEqual(['div', { class: 'container', id: 'main' }]) + }) + + it('should render slot tag as 0 (content hole)', () => { + const result = h('slot', {}) + + expect(result).toBe(0) + }) + + it('should handle function components', () => { + const Component = (props: any) => ['span', props] + const result = h(Component, { class: 'test' }) + + expect(result).toEqual(['span', { class: 'test' }]) + }) + + it('should keep a function component as a single child', () => { + const Component = (props: any) => ['span', props] + const component = h(Component, { class: 'test' }) + const result = h('div', { children: [component, 'text'] }) + + expect(result).toEqual(['div', {}, ['span', { class: 'test' }], 'text']) + }) + + it('should throw error for svg elements', () => { + expect(() => h('svg', {})).toThrow( + 'SVG elements are not supported in the JSX syntax, use the array syntax instead', + ) + }) + }) + + describe('single child rendering', () => { + it('should render element with single text child', () => { + const result = h('div', { children: 'Hello World' }) + + expect(result).toEqual(['div', {}, 'Hello World']) + }) + + it('should render element with single element child', () => { + const child = h('span', { children: 'text' }) + const result = h('div', { children: child }) + + expect(result).toEqual(['div', {}, ['span', {}, 'text']]) + }) + + it('should render element with single child and attributes', () => { + const child = h('span', { children: 'text' }) + const result = h('div', { class: 'container', children: child }) + + expect(result).toEqual(['div', { class: 'container' }, ['span', {}, 'text']]) + }) + }) + + describe('nested sibling rendering', () => { + it('should render multiple sibling elements by spreading them', () => { + // Simulates JSX:
AB
+ const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = h('div', { children: [child1, child2] }) + + // Expected: ["div", {}, ["span", {}, "A"], ["span", {}, "B"]] + // NOT: ["div", {}, [["span", {}, "A"], ["span", {}, "B"]]] + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should render three sibling elements correctly', () => { + const child1 = h('span', { children: 'First' }) + const child2 = h('span', { children: 'Second' }) + const child3 = h('span', { children: 'Third' }) + const result = h('div', { children: [child1, child2, child3] }) + + expect(result).toEqual([ + 'div', + {}, + ['span', {}, 'First'], + ['span', {}, 'Second'], + ['span', {}, 'Third'], + ]) + }) + + it('should render elements with attributes and multiple children', () => { + // Simulates JSX:
AB
+ const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = h('div', { class: 'container', children: [child1, child2] }) + + expect(result).toEqual(['div', { class: 'container' }, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should render nested structures with multiple siblings at each level', () => { + // Simulates complex nested JSX + const innerChild1 = h('span', { children: 'Title' }) + const innerChild2 = h('span', { children: 'Value' }) + const middleChild = h('div', { class: 'stat', children: [innerChild1, innerChild2] }) + + const result = h('div', { class: 'stats', children: [middleChild] }) + + expect(result).toEqual([ + 'div', + { class: 'stats' }, + ['div', { class: 'stat' }, ['span', {}, 'Title'], ['span', {}, 'Value']], + ]) + }) + + it('should handle the issue example: stats card with nested siblings', () => { + // This is the exact scenario from GitHub issue #6949 + const statTitle = jsx('div', { class: 'stat-title', children: 'Title' }) + const statValue = jsx('div', { class: 'stat-value', children: '1,000' }) + const stat = jsxs('div', { class: 'stat', children: [statTitle, statValue] }) + const result = jsx('div', { class: 'stats', children: stat }) + + expect(result).toEqual([ + 'div', + { class: 'stats' }, + [ + 'div', + { class: 'stat' }, + ['div', { class: 'stat-title' }, 'Title'], + ['div', { class: 'stat-value' }, '1,000'], + ], + ]) + }) + }) + + describe('edge cases', () => { + it('should handle empty children array', () => { + const result = h('div', { children: [] }) + + expect(result).toEqual(['div', {}]) + }) + + it('should handle undefined children', () => { + const result = h('div', { children: undefined }) + + expect(result).toEqual(['div', {}]) + }) + + it('should handle null children', () => { + const result = h('div', { children: null }) + + expect(result).toEqual(['div', {}]) + }) + + it('should filter out null and undefined from children array', () => { + const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = h('div', { children: [child1, null, child2, undefined] }) + + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should handle array with all null/undefined children', () => { + const result = h('div', { children: [null, undefined, null] }) + + expect(result).toEqual(['div', {}]) + }) + }) + + describe('Fragment component', () => { + it('should return children array from Fragment', () => { + const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const result = Fragment({ children: [child1, child2] }) + + expect(result).toEqual([ + ['span', {}, 'A'], + ['span', {}, 'B'], + ]) + }) + + it('should work with Fragment as a parent component', () => { + const child1 = h('div', { children: 'First' }) + const child2 = h('div', { children: 'Second' }) + const fragmentResult = Fragment({ children: [child1, child2] }) + + expect(fragmentResult).toEqual([ + ['div', {}, 'First'], + ['div', {}, 'Second'], + ]) + }) + + it('should spread fragment children when nested', () => { + const child1 = h('span', { children: 'A' }) + const child2 = h('span', { children: 'B' }) + const fragment = Fragment({ children: [child1, child2] }) + const result = h('div', { children: fragment }) + + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should spread a Fragment between sibling elements', () => { + const before = h('span', { children: 'Before' }) + const nestedFragment = Fragment({ + children: [h('span', { children: 'Inside A' }), h('span', { children: 'Inside B' })], + }) + const fragment = Fragment({ children: [nestedFragment] }) + const after = h('span', { children: 'After' }) + const result = h('div', { children: [before, fragment, after] }) + + expect(result).toEqual([ + 'div', + {}, + ['span', {}, 'Before'], + ['span', {}, 'Inside A'], + ['span', {}, 'Inside B'], + ['span', {}, 'After'], + ]) + }) + }) + + describe('content hole (slot) integration', () => { + it('should render element with slot (0) as child', () => { + const slot = h('slot', {}) + const result = h('div', { children: slot }) + + expect(result).toEqual(['div', {}, 0]) + }) + + it('should render element with attributes and slot', () => { + const slot = h('slot', {}) + const result = h('div', { class: 'content', children: slot }) + + expect(result).toEqual(['div', { class: 'content' }, 0]) + }) + + it('should handle slot in nested structure', () => { + const slot = h('slot', {}) + const inner = h('div', { class: 'inner', children: slot }) + const result = h('div', { class: 'outer', children: inner }) + + expect(result).toEqual(['div', { class: 'outer' }, ['div', { class: 'inner' }, 0]]) + }) + }) + + describe('mixed content', () => { + it('should handle mixed text and element children', () => { + const element = h('strong', { children: 'bold' }) + const result = h('p', { children: [element] }) + + expect(result).toEqual(['p', {}, ['strong', {}, 'bold']]) + }) + + it('should handle multiple mixed content types', () => { + const bold = h('strong', { children: 'important' }) + const italic = h('em', { children: 'emphasis' }) + const result = h('p', { children: [bold, italic] }) + + expect(result).toEqual(['p', {}, ['strong', {}, 'important'], ['em', {}, 'emphasis']]) + }) + + it('should keep text and element siblings separate', () => { + const element = jsx('strong', { children: 'bold' }) + const result = jsxs('p', { children: ['text', element] }) + + expect(result).toEqual(['p', {}, 'text', ['strong', {}, 'bold']]) + }) + + it('should keep text and slot siblings separate', () => { + const slot = jsx('slot', {}) + const result = jsxs('div', { children: ['text', slot] }) + + expect(result).toEqual(['div', {}, 'text', 0]) + }) }) - it('keeps text and slot siblings separate', () => { - const slot = jsx('slot', {}) + describe('real-world scenarios', () => { + it('should handle complex node rendering structure', () => { + // Simulates a complex custom node like a callout with icon and content + const icon = h('span', { class: 'icon', children: '📝' }) + const title = h('div', { class: 'title', children: 'Note' }) + const content = h('div', { class: 'content', children: h('slot', {}) }) + const header = h('div', { class: 'header', children: [icon, title] }) + + const result = h('div', { class: 'callout', children: [header, content] }) + + expect(result).toEqual([ + 'div', + { class: 'callout' }, + [ + 'div', + { class: 'header' }, + ['span', { class: 'icon' }, '📝'], + ['div', { class: 'title' }, 'Note'], + ], + ['div', { class: 'content' }, 0], + ]) + }) + + it('should handle deeply nested sibling structures', () => { + // Level 3 + const leaf1 = h('span', { children: 'A' }) + const leaf2 = h('span', { children: 'B' }) + + // Level 2 + const branch = h('div', { class: 'branch', children: [leaf1, leaf2] }) + + // Level 1 + const root = h('div', { class: 'root', children: [branch] }) + + expect(root).toEqual([ + 'div', + { class: 'root' }, + ['div', { class: 'branch' }, ['span', {}, 'A'], ['span', {}, 'B']], + ]) + }) + }) + + describe('JSX boundary handling', () => { + it('should spread array of DOMOutputSpecArrays, not treat as single child', () => { + // Array of multiple DOMOutputSpecArrays should be spread + const children = [ + ['span', {}, 'A'], + ['span', {}, 'B'], + ] as any + const result = h('div', { children }) + + expect(result).toEqual(['div', {}, ['span', {}, 'A'], ['span', {}, 'B']]) + }) + + it('should handle array starting with 0 as multiple children', () => { + const children = [0, 'text'] as any + const result = h('div', { children }) + + expect(result).toEqual(['div', {}, 0, 'text']) + }) + + it('should handle single 0 (content hole) as child', () => { + // Single 0 is the content hole marker + const result = h('div', { children: 0 }) - expect(jsxs('p', { children: ['Before ', slot] })).toEqual(['p', {}, 'Before ', 0]) + expect(result).toEqual(['div', {}, 0]) + }) }) }) diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts index ba5013b785..6ed2e117e3 100644 --- a/packages/core/src/jsx-runtime.ts +++ b/packages/core/src/jsx-runtime.ts @@ -13,13 +13,43 @@ export type DOMOutputSpecArray = | [string, Attributes, DOMOutputSpecArray | 0] | [string, DOMOutputSpecArray] +// Child lists and DOM output specs are both arrays, so track JSX boundaries by identity. +const jsxElements = new WeakSet() +const jsxFragments = new WeakSet() + +/** Create a new JSX element from the given spec */ +function createJSXElement(spec: unknown[]): DOMOutputSpecArray { + const element = spec as DOMOutputSpecArray + + jsxElements.add(element) + + return element +} + +/** Check if a spec is a JSX element */ +function isJSXElement(value: unknown): value is DOMOutputSpecArray { + return Array.isArray(value) && jsxElements.has(value as DOMOutputSpecArray) +} + +function flattenFragmentChildren(children: unknown[]): unknown[] { + return children.flatMap(child => { + if (child == null) { + return [] + } + + if (Array.isArray(child) && jsxFragments.has(child) && !isJSXElement(child)) { + return flattenFragmentChildren(child) + } + + return [child] + }) +} + // JSX types for Tiptap's JSX runtime // These types only apply when using @jsxImportSource @tiptap/core -// oxlint-disable-next-lineno-namespace -export namespace JSX { +export declare namespace JSX { export type Element = DOMOutputSpecArray export interface IntrinsicElements { - // oxlint-disable-next-lineno-explicit-any [key: string]: any } export interface ElementChildrenAttribute { @@ -27,21 +57,21 @@ export namespace JSX { } } +type JSXChild = DOMOutputSpecElement | string | null | undefined | JSXChild[] + export type JSXRenderer = ( tag: 'slot' | string | ((props?: Attributes) => DOMOutputSpecArray | DOMOutputSpecElement), props?: Attributes, - ...children: JSXRenderer[] + ...children: JSXChild[] ) => DOMOutputSpecArray | DOMOutputSpecElement -export function Fragment(props: { children: JSXRenderer[] }) { +export function Fragment(props: { children: JSXChild[] }) { + jsxFragments.add(props.children) + return props.children } -function render( - tag: Parameters[0], - attributes: Attributes | undefined, - hasMultipleChildren: boolean, -) { +function render(tag: Parameters[0], attributes: Attributes | undefined) { // Treat the slot tag as the Prosemirror hole to render content into if (tag === 'slot') { return 0 @@ -49,7 +79,13 @@ function render( // If the tag is a function, call it with the props if (tag instanceof Function) { - return tag(attributes) + const result = tag(attributes) + + if (Array.isArray(result) && !isJSXElement(result) && !jsxFragments.has(result)) { + return createJSXElement(result) + } + + return result } const { children, ...rest } = attributes ?? {} @@ -60,24 +96,46 @@ function render( ) } - if (hasMultipleChildren && Array.isArray(children)) { - return [tag, rest, ...children] as DOMOutputSpecArray + // Handle children array by spreading elements + if (Array.isArray(children)) { + if (isJSXElement(children)) { + return createJSXElement([tag, rest, children]) + } + + if (children.length === 0) { + // Empty array means no children + return createJSXElement([tag, rest]) + } + + const flattenedChildren = flattenFragmentChildren(children) + + if (flattenedChildren.length === 0) { + return createJSXElement([tag, rest]) + } + + // Spread children into the result array + return createJSXElement([tag, rest, ...flattenedChildren]) + } + + // Single child or no children + if (children !== undefined && children !== null) { + return createJSXElement([tag, rest, children]) } - return [tag, rest, children] + return createJSXElement([tag, rest]) } -export const h: JSXRenderer = (tag, attributes) => render(tag, attributes, false) +export const h: JSXRenderer = (tag, attributes) => render(tag, attributes) -export const jsxs: JSXRenderer = (tag, attributes) => render(tag, attributes, true) +export const jsxs: JSXRenderer = (tag, attributes) => render(tag, attributes) export const jsxDEV = ( tag: Parameters[0], attributes?: Attributes, _key?: unknown, - isStaticChildren?: boolean, + _isStaticChildren?: boolean, ) => { - return render(tag, attributes, Boolean(isStaticChildren)) + return render(tag, attributes) } // See diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed1d949da1..5e26a46664 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,12 @@ importers: '@tiptap/pm': specifier: workspace:^ version: link:../pm + '@tiptap/y-tiptap': + specifier: ^3.0.7 + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + yjs: + specifier: ^13.6.23 + version: 13.6.23 packages/core: devDependencies: