diff --git a/.changeset/ai-surface-blocknote-page-markdown.md b/.changeset/ai-surface-blocknote-page-markdown.md new file mode 100644 index 000000000..7870a3a86 --- /dev/null +++ b/.changeset/ai-surface-blocknote-page-markdown.md @@ -0,0 +1,30 @@ +--- +'@xnetjs/plugins': major +--- + +AI page-markdown surface re-targeted to the BlockNote editor (exploration 0312). + +- **Breaking**: the page-markdown apply adapter mode `'tiptap-yjs'` is renamed + to `'blocknote-yjs'` in `AiPageMarkdownApplyAdapterResult['mode']` and + `AiPageMarkdownApplyResult['mode']`. Adapters that returned + `mode: 'tiptap-yjs'` must return `'blocknote-yjs'` (or `'yjs'`/`'custom'`). +- New Yjs-fragment ↔ markdown conversion for BlockNote (`content-v4`) + documents, dependency-light (walks the Yjs XML tree directly, no editor/DOM): + - `xnetPageFragmentToMarkdown(doc)` reads the BlockNote fragment + (paragraph/heading/lists/check items/code/quote/callout/table + inline + `mention`/`hashtag`/`wikilink`/`inlineMath` atoms), falling back to the + legacy TipTap `content` fragment when `content-v4` is empty + (`blockNoteFragmentToMarkdown` / `legacyFragmentToMarkdown` are also + exported). + - `replaceXNetPageFragmentWithMarkdown(doc, markdown)` writes the AI + markdown subset (paragraphs, headings, bullet/numbered/check lists with + nesting, fenced code, quotes, callouts, wikilinks) as BlockNote PM XML — + `blockGroup > blockContainer` (unique `id` per block) wrappers — in one + Yjs transaction. + - `createBlockNotePageMarkdownAdapter({ resolveDoc })` packages both as an + `AiPageMarkdownApplyAdapter` (plus `readMarkdown`) for + `xnet_apply_page_markdown`, replacing the TipTap-era document bridge. + - `XNET_PAGE_FRAGMENT_FIELD` (`'content-v4'`) and + `XNET_PAGE_LEGACY_FRAGMENT_FIELD` (`'content'`) constants. +- `@xnetjs/plugins` now depends on `yjs`; the unused `@tiptap/core` + devDependency is gone. diff --git a/.changeset/blocknote-editor-contribution.md b/.changeset/blocknote-editor-contribution.md new file mode 100644 index 000000000..b85660f76 --- /dev/null +++ b/.changeset/blocknote-editor-contribution.md @@ -0,0 +1,19 @@ +--- +'@xnetjs/plugins': major +--- + +`EditorContribution` carries BlockNote specs instead of TipTap extensions (exploration 0312). + +- **Breaking**: `EditorContribution.extension` (TipTap `Extension`) and + `EditorContribution.toolbar` (`ToolbarContribution`, removed entirely) are + gone. Plugins now contribute `blockSpecs` / `inlineContentSpecs` / + `styleSpecs` (opaque BlockNote spec objects keyed by spec name) plus + behavior-only `slashMenuItems`. +- **Breaking**: the editor schema-skew guard is spec-based — + `isSchemaDefiningExtension` is replaced by `isSchemaDefiningContribution`, + and `findEditorSchemaRisks` / `warnOnEditorSchemaRisks` take the host's + statically bundled spec names and flag any contributed spec outside that + set (0205 invariant: schema specs must be identical across all + collaborators or Yjs silently drops content). +- `SlashCommandContext.editor` is now a BlockNote editor instance. +- The `@tiptap/core` dependency is removed. diff --git a/.changeset/blocknote-fragment-readers.md b/.changeset/blocknote-fragment-readers.md new file mode 100644 index 000000000..a66f2aa6e --- /dev/null +++ b/.changeset/blocknote-fragment-readers.md @@ -0,0 +1,29 @@ +--- +'@xnetjs/data': minor +'@xnetjs/history': minor +'@xnetjs/react': minor +'@xnetjs/runtime': patch +--- + +Yjs fragment readers understand the BlockNote document schema (exploration 0312). + +Documents now live in the `content-v4` fragment using BlockNote's ProseMirror +shape (`blockGroup > blockContainer > blockContent`); the legacy TipTap +`content` fragment remains readable as a fallback until each doc is lazily +imported. + +- `@xnetjs/data`: `getRichTextPlainText` extracts text from BlockNote-shaped + rich-text cells, including the new inline atoms (`mention` → `@label`, + `hashtag` → `#name`, `wikilink` → title, `inlineMath` → latex), while still + reading legacy TipTap-shaped cells. +- `@xnetjs/history`: version-diff text extraction prefers `content-v4` (legacy + `content` fallback) and renders BlockNote inline atoms as readable text. +- `@xnetjs/react`: new `useMergedEditorContributions` / + `mergeEditorContributions` (+ `MergedEditorContributions` type) collect + plugin-contributed BlockNote `blockSpecs`/`inlineContentSpecs`/`styleSpecs` + and slash menu items from the plugin registry, running the editor + schema-skew guard (`warnOnEditorSchemaRisks`) against the host's statically + bundled spec names and excluding un-bundled (skew-hazard) specs. +- `@xnetjs/runtime`: blob-CID retention scanning now also walks the + `content-v4` and `content` fragments, so blobs referenced from page + documents are discovered. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1be774aa..3ce5bcf65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,7 +182,7 @@ jobs: run: pnpm --filter @xnetjs/e2e-tests exec playwright install-deps chromium webkit - name: Run focused editor interaction tests - run: pnpm --filter @xnetjs/editor exec vitest run src/nodeviews/hooks/useNodeFocus.test.ts src/components/FloatingToolbar.test.tsx src/components/SlashMenu.test.tsx src/components/editor-ux-state.test.ts src/extensions/live-preview/inline-marks.integration.test.ts + run: pnpm --filter @xnetjs/editor exec vitest run src/blocknote/collab-convergence.test.tsx src/blocknote/doc-utils.test.ts src/blocknote/legacy-import.test.ts - name: Start web app run: pnpm --filter xnet-web dev --host 127.0.0.1 --port 5173 > /tmp/xnet-web.log 2>&1 & diff --git a/.storybook/shims/xnet-plugins-browser.ts b/.storybook/shims/xnet-plugins-browser.ts index 2411a7ade..8ccf5b126 100644 --- a/.storybook/shims/xnet-plugins-browser.ts +++ b/.storybook/shims/xnet-plugins-browser.ts @@ -106,3 +106,12 @@ export type { } from '../../packages/plugins/src/workspace' export type { SlotContribution, SlotRegion } from '../../packages/plugins/src/contributions' export { evaluateInstallConsent, scaffoldPlugin } from '../../packages/plugins/src/ecosystem' + +// Editor schema-skew guard (0205, spec-based since 0312) — used by +// packages/react's useMergedEditorContributions. +export { + findEditorSchemaRisks, + isSchemaDefiningContribution, + warnOnEditorSchemaRisks +} from '../../packages/plugins/src/editor-schema-safety' +export type { EditorSchemaRisk } from '../../packages/plugins/src/editor-schema-safety' diff --git a/apps/electron/src/renderer/components/MeetingsView.tsx b/apps/electron/src/renderer/components/MeetingsView.tsx index a6c6a0a90..8071435b1 100644 --- a/apps/electron/src/renderer/components/MeetingsView.tsx +++ b/apps/electron/src/renderer/components/MeetingsView.tsx @@ -4,12 +4,12 @@ * The recorder/list/detail cores are shared from @xnetjs/views (0277 * shared-view-core pattern); this wrapper keeps the desktop deltas: local * mode state (the desktop shell has no router — same idiom as - * DataWorkspaceView), the EditorSurface notes editor, and the close + * DataWorkspaceView), the XNetEditor notes editor, and the close * affordance back to the home canvas. `window.xnetMeetings` is present here, * so the recorder runs at the system-audio tier with the native engines. */ -import { EditorSurface } from '@xnetjs/editor/react' +import { XNetEditor } from '@xnetjs/editor/react' import { MeetingDetailView, MeetingRecorderView, MeetingsListView } from '@xnetjs/views' import { ArrowLeft, X } from 'lucide-react' import React, { useState } from 'react' @@ -58,7 +58,7 @@ export function MeetingsView({ onClose }: MeetingsViewProps): React.ReactElement } + renderNotes={({ doc }) => } /> ) : ( ['extensions']> +type TaskEmbedFilters = Parameters[0] + +/** + * Map the BlockNote task-view embed config (0312 vocabulary) onto the + * filters TaskCollectionEmbed expects (the pre-0312 vocabulary). Defaults + * match the old task-view extension: open tasks, hierarchy on. (Same + * adapter as the web Editor.) + */ +function toTaskEmbedFilters( + viewConfig: TaskViewConfig +): Pick { + const dueMap = { + overdue: 'overdue', + today: 'today', + week: 'next-7-days', + all: 'any' + } as const + const statusMap = { open: 'open', completed: 'done', all: 'all' } as const + return { + scope: viewConfig.scope === 'page' ? 'current-page' : 'all', + assignee: viewConfig.scope === 'assigned' ? 'me' : 'any', + dueDate: viewConfig.dueDate ? dueMap[viewConfig.dueDate] : 'any', + status: viewConfig.status ? statusMap[viewConfig.status] : 'open', + showHierarchy: viewConfig.showHierarchy ?? true + } +} export function PageView({ docId, minimalChrome = false }: PageViewProps) { const { did } = useIdentity() @@ -46,20 +74,6 @@ export function PageView({ docId, minimalChrome = false }: PageViewProps) { const onFileUpload = useFileUpload() const onFileDownload = useFileDownload() - // Get editor extensions from plugins (reactive - updates when plugins change) - // Uses safe version that returns [] if plugin system isn't ready - const editorContributions = useEditorExtensionsSafe() - const pluginExtensions = editorContributions.map((c) => c.extension) as EditorExtensions - - // Wait for plugin-contributed editor extensions to be registered before - // mounting the editor. BundledPluginInstaller installs plugins (like Mermaid) - // asynchronously. If the editor mounts before Mermaid is registered, Yjs - // content containing mermaid nodes will crash ProseMirror ("toDOM is not a - // function"). We gate on editorContributions being populated, which means - // the plugin's activate() has run and contributions are registered. - const pluginRegistry = usePluginRegistryOptional() - const pluginsReady = pluginRegistry ? editorContributions.length > 0 : false - // Page data and Y.Doc const { data: page, @@ -75,52 +89,97 @@ export function PageView({ docId, minimalChrome = false }: PageViewProps) { did: did ?? undefined }) const { handleTasksChange } = usePageTaskSync({ pageId: docId }) + // Adapt the editor's checklist snapshot (0312 BlockNote shape — references + // carry url/title only) to the task projection input. + const handlePageTasksChange = useCallback( + (tasks: PageTaskSnapshot[]) => { + handleTasksChange( + tasks.map((task) => ({ + ...task, + references: task.references.map((reference) => ({ + url: reference.url, + title: reference.title, + provider: null, + kind: null, + refId: null, + subtitle: null, + icon: null, + embedUrl: null, + metadata: '{}' + })) + })) + ) + }, + [handleTasksChange] + ) const mentionSuggestions = useMemo( () => buildTaskMentionSuggestions(presence, did), [did, presence] ) - // ─── Comments Integration (shared state machine, 0276) ─────────────────────── + // ─── Comments (0312: node-backed thread panel only) ────────────────────────── + // + // Inline comment anchors (text marks in the document) were retired with the + // TipTap editor. Threads still live as comment nodes and stay readable and + // actionable from the sidebar; creating new inline comments returns with the + // BlockNote ThreadStore spike (0312 Phase 4). const { threads, - unresolvedCount, - threadDataMap, - sidebarThreads, - currentThread, - orphanedThreads, - orphanedCollapsed, - toggleOrphanedCollapsed, - popoverState, - newCommentState, - editorRef, - handleEditorReady, - commentExtensions, - showThreadPopover, - handlePopoverMouseEnter, - handlePopoverMouseLeave, - handleDismiss, - handleUpgradeToFull, - handleReply, - handleResolve, - handleReopen, - handleDelete, - handleEdit, - handleCreateComment, - handleSubmitNewComment, - handleCancelNewComment, - handleSidebarSelectThread, - handleSidebarReply, - handleSidebarResolve, - handleSidebarReopen, - handleSidebarDelete, - handleSidebarEdit, - handleDismissOrphaned, - handleReattachOrphaned - } = usePageComments({ docId, dismissPopoverOnCaretExit: true }) + replyTo, + resolveThread, + reopenThread, + deleteComment, + editComment, + unresolvedCount + } = useComments({ nodeId: docId, anchorType: 'text' }) + + const sidebarThreads = useMemo( + () => + threads.map((thread) => ({ + root: { + id: thread.root.id, + author: thread.root.properties.createdBy, + content: thread.root.properties.content, + createdAt: thread.root.createdAt, + edited: thread.root.properties.edited, + editedAt: thread.root.properties.editedAt, + replyToUser: thread.root.properties.replyToUser, + replyToCommentId: thread.root.properties.replyToCommentId + }, + replies: thread.replies.map((r) => ({ + id: r.id, + author: r.properties.createdBy, + content: r.properties.content, + createdAt: r.createdAt, + edited: r.properties.edited, + editedAt: r.properties.editedAt, + replyToUser: r.properties.replyToUser, + replyToCommentId: r.properties.replyToCommentId + })), + resolved: thread.root.properties.resolved + })), + [threads] + ) const [sidebarOpen, setSidebarOpen] = useState(false) + const [selectedThreadId, setSelectedThreadId] = useState(null) const titleInputRef = useRef(null) + const editorRef = useRef(null) + + const handleEditorReady = useCallback((editor: XNetEditorInstance) => { + editorRef.current = editor + }, []) + + const focusEditor = useCallback((position: 'start' | 'end') => { + const editor = editorRef.current + if (!editor) return + const blocks = editor.document + const target = position === 'start' ? blocks[0] : blocks[blocks.length - 1] + if (!target) return + editor.setTextCursorPosition(target, position) + editor.focus() + }, []) const handleEditorSurfaceMouseDown = useCallback( (event: React.MouseEvent) => { @@ -140,23 +199,24 @@ export function PageView({ docId, minimalChrome = false }: PageViewProps) { ].join(',') ) - if (interactiveTarget || !editorRef.current) { + const editorDom = editorRef.current?.domElement + if (interactiveTarget || !editorDom) { return } event.preventDefault() const focusPosition = resolvePageEditorFocusPosition( event.clientY, - editorRef.current.view.dom.getBoundingClientRect() + editorDom.getBoundingClientRect() ) - editorRef.current.commands.focus(focusPosition) + focusEditor(focusPosition) }, - [editorRef] + [focusEditor] ) const handleTitleSubmit = useCallback(() => { - editorRef.current?.commands.focus('start') - }, [editorRef]) + focusEditor('start') + }, [focusEditor]) const handleBodyBackspaceAtStart = useCallback(() => { const titleInput = titleInputRef.current @@ -168,79 +228,46 @@ export function PageView({ docId, minimalChrome = false }: PageViewProps) { return true }, []) - // ─── Sidebar hover highlights (desktop-only affordance) ────────────────────── - - const hoveredThreadRef = useRef(null) - const leaveTimerRef = useRef | null>(null) - - const handleSidebarHoverThread = useCallback((threadId: string) => { - // Cancel any pending leave — user moved to another thread or re-entered - if (leaveTimerRef.current) { - clearTimeout(leaveTimerRef.current) - leaveTimerRef.current = null - } - - // Clear previous thread's highlights if switching threads - if (hoveredThreadRef.current && hoveredThreadRef.current !== threadId) { - document.querySelectorAll('.xnet-comment-sidebar-hover').forEach((el) => { - el.classList.remove('xnet-comment-sidebar-hover') - }) - } - - hoveredThreadRef.current = threadId - - // Find all mark elements for this thread and add the hover class - const marks = document.querySelectorAll(`[data-comment-id="${threadId}"]`) - marks.forEach((el) => el.classList.add('xnet-comment-sidebar-hover')) - // Scroll the first mark into view - if (marks.length > 0) { - marks[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) - } + const handleSidebarSelectThread = useCallback((threadId: string) => { + setSelectedThreadId((prev) => (prev === threadId ? null : threadId)) }, []) - const handleSidebarLeaveThread = useCallback(() => { - // Delay removal to avoid flicker from scroll-induced spurious mouseLeave events. - // If the user re-enters the same thread (or enters another) within the window, - // handleSidebarHoverThread will cancel this timer. - if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current) - leaveTimerRef.current = setTimeout(() => { - hoveredThreadRef.current = null - document.querySelectorAll('.xnet-comment-sidebar-hover').forEach((el) => { - el.classList.remove('xnet-comment-sidebar-hover') - }) - }, 150) - }, []) + const handleSidebarReply = useCallback( + (threadId: string, content: string) => { + void replyTo(threadId, content) + }, + [replyTo] + ) - // Combine plugin extensions with comment extensions - const allExtensions = useMemo( - () => [...pluginExtensions, ...commentExtensions], - [pluginExtensions, commentExtensions] + const handleSidebarResolve = useCallback( + (threadId: string) => { + void resolveThread(threadId) + }, + [resolveThread] ) - // Debug: log when popover should show but thread not found - useEffect(() => { - if (popoverState.visible && popoverState.threadId && !currentThread) { - console.log('[Comments] Popover visible but thread not found yet:', popoverState.threadId) - console.log( - '[Comments] Available threads:', - threads.map((t) => t.root.id) - ) - } - }, [popoverState, currentThread, threads]) + const handleSidebarReopen = useCallback( + (threadId: string) => { + void reopenThread(threadId) + }, + [reopenThread] + ) - const handleSelectOrphaned = useCallback( + const handleSidebarDelete = useCallback( (commentId: string) => { - // Open the popover for this orphaned comment - const thread = threadDataMap.get(commentId) - if (thread) { - // Since orphaned comments don't have anchor elements, use coordinates - showThreadPopover(commentId, null) // Will need to position differently - } + void deleteComment(commentId) }, - [threadDataMap, showThreadPopover] + [deleteComment] ) - if (loading || !doc || !pluginsReady) { + const handleSidebarEdit = useCallback( + (commentId: string, newContent: string) => { + void editComment(commentId, newContent) + }, + [editComment] + ) + + if (loading || !doc) { return (

Loading...

@@ -282,56 +309,36 @@ export function PageView({ docId, minimalChrome = false }: PageViewProps) { {/* Editor + Sidebar horizontal layout */}
{/* Editor */} - ( - - )} +
- {/* Orphaned Comments Section */} - {orphanedThreads.length > 0 && ( -
- ( + -
- )} + )} + /> - +
{/* Comments Sidebar */} setSidebarOpen(false)} onSelectThread={handleSidebarSelectThread} - selectedThreadId={popoverState.threadId} + selectedThreadId={selectedThreadId} onReply={handleSidebarReply} onResolve={handleSidebarResolve} onReopen={handleSidebarReopen} onDelete={handleSidebarDelete} onEdit={handleSidebarEdit} - onHoverThread={handleSidebarHoverThread} - onLeaveThread={handleSidebarLeaveThread} />
- - {/* Comment Popover */} - {popoverState.visible && - popoverState.anchor && - (currentThread ? ( - - ) : ( -
-
Loading comment...
-
- ))} - - {/* New Comment Input */} - {newCommentState?.visible && ( - - )}
) } @@ -412,70 +380,3 @@ function SyncIndicator({ status, peerCount }: { status: SyncStatus; peerCount: n ) } - -// ─── New Comment Input ───────────────────────────────────────────────────────── - -interface NewCommentInputProps { - onSubmit: (content: string) => void - onCancel: () => void -} - -function NewCommentInput({ onSubmit, onCancel }: NewCommentInputProps) { - const [content, setContent] = useState('') - const textareaRef = useRef(null) - - // Focus on mount - useEffect(() => { - textareaRef.current?.focus() - }, []) - - const handleSubmit = () => { - if (content.trim()) { - onSubmit(content) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - handleSubmit() - } - if (e.key === 'Escape') { - onCancel() - } - } - - return ( -
-
-
Add Comment
-