diff --git a/api/crates/application/src/plugins/ports/plugin_repository.rs b/api/crates/application/src/plugins/ports/plugin_repository.rs index 2bd064eb..f70113d6 100644 --- a/api/crates/application/src/plugins/ports/plugin_repository.rs +++ b/api/crates/application/src/plugins/ports/plugin_repository.rs @@ -52,6 +52,14 @@ pub trait PluginRepository: Send + Sync { patch: &JsonValue, ) -> PortResult>; + async fn append_record_array_item( + &self, + record_id: Uuid, + field: &str, + item: &JsonValue, + patch: &JsonValue, + ) -> PortResult>; + async fn delete_record(&self, record_id: Uuid) -> PortResult; async fn get_record(&self, record_id: Uuid) -> PortResult>; diff --git a/api/crates/application/src/plugins/use_cases/exec_action.rs b/api/crates/application/src/plugins/use_cases/exec_action.rs index 3550a0a5..131706df 100644 --- a/api/crates/application/src/plugins/use_cases/exec_action.rs +++ b/api/crates/application/src/plugins/use_cases/exec_action.rs @@ -329,6 +329,68 @@ where .map_err(|err| PluginEffectError::from(anyhow::Error::from(err)))?; } } + "appendRecordArrayItem" => { + policy::ensure_plugin_permission( + permissions, + policy::PLUGIN_PERMISSION_DOC_WRITE, + )?; + policy::ensure_workspace_can_edit_documents(workspace_permissions)?; + let Some(record_id) = effect + .get("recordId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + else { + continue; + }; + let Some(field) = effect + .get("field") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 128) + else { + continue; + }; + let Some(rec) = self + .plugin_repo + .get_record(record_id) + .await + .map_err(|err| PluginEffectError::from(anyhow::Error::from(err)))? + else { + continue; + }; + policy::ensure_record_owned_by_plugin(&rec.plugin, plugin)?; + if rec.scope != PluginRecordScope::Doc { + continue; + } + self.validate_doc_scope( + workspace_id, + Some(rec.scope_id), + allowed_doc_id, + doc_id_created, + actor, + true, + ) + .await?; + let item = effect + .get("item") + .cloned() + .unwrap_or(serde_json::Value::Null); + let mut patch = effect + .get("patch") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if !patch.is_object() { + patch = serde_json::json!({}); + } + if let Some(obj) = patch.as_object_mut() { + obj.remove(field); + } + let _ = self + .plugin_repo + .append_record_array_item(record_id, field, &item, &patch) + .await + .map_err(|err| PluginEffectError::from(anyhow::Error::from(err)))?; + } "deleteRecord" => { policy::ensure_plugin_permission( permissions, diff --git a/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs b/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs index f817f54f..ace5d619 100644 --- a/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs +++ b/api/crates/infrastructure/src/plugins/db/repositories/plugin_repository_sqlx/mod.rs @@ -146,6 +146,59 @@ impl PluginRepository for SqlxPluginRepository { out.map_err(Into::into) } + async fn append_record_array_item( + &self, + record_id: Uuid, + field: &str, + item: &JsonValue, + patch: &JsonValue, + ) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"UPDATE plugin_records + SET data = jsonb_set( + data || $4::jsonb, + ARRAY[$2], + COALESCE( + CASE + WHEN jsonb_typeof(data -> $2) = 'array' THEN data -> $2 + ELSE '[]'::jsonb + END, + '[]'::jsonb + ) || jsonb_build_array($3::jsonb), + true + ), + updated_at = now() + WHERE id = $1 + RETURNING id, plugin, scope, scope_id, kind, data, created_at, updated_at"#, + ) + .bind(record_id) + .bind(field) + .bind(item) + .bind(patch) + .fetch_optional(&self.pool) + .await?; + row.map(|r| { + let scope_raw: String = r.get("scope"); + let scope = PluginRecordScope::parse(&scope_raw) + .ok_or_else(|| anyhow::anyhow!("invalid_plugin_record_scope"))?; + Ok(PluginRecord { + id: r.get("id"), + plugin: r.get("plugin"), + scope, + scope_id: r.get("scope_id"), + kind: r.get("kind"), + data: r.get("data"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + }) + }) + .transpose() + } + .await; + out.map_err(Into::into) + } + async fn delete_record(&self, record_id: Uuid) -> PortResult { let out: anyhow::Result = async { let res = sqlx::query("DELETE FROM plugin_records WHERE id = $1") diff --git a/app/src/entities/plugin/api/index.ts b/app/src/entities/plugin/api/index.ts index 4a336ad7..9ba00125 100644 --- a/app/src/entities/plugin/api/index.ts +++ b/app/src/entities/plugin/api/index.ts @@ -14,6 +14,10 @@ import { import type { ManifestItem as ClientManifestItem } from '@/shared/api/client' export type PluginManifestItem = ClientManifestItem +export type PluginRecordListOptions = { + limit?: number | null + offset?: number | null +} export const pluginKeys = { manifest: () => ['plugins', 'manifest'] as const, @@ -59,9 +63,20 @@ export async function listPluginRecords( pluginId: string, docId: string, kind: string, + optionsOrToken?: PluginRecordListOptions | string, token?: string, ) { - return withShareAuthorization(token, () => apiListRecords({ plugin: pluginId, docId, kind })) + const options = typeof optionsOrToken === 'string' ? undefined : optionsOrToken + const authToken = typeof optionsOrToken === 'string' ? optionsOrToken : token + return withShareAuthorization(authToken, () => + apiListRecords({ + plugin: pluginId, + docId, + kind, + limit: options?.limit ?? undefined, + offset: options?.offset ?? undefined, + }), + ) } export async function createPluginRecord( @@ -76,12 +91,14 @@ export async function createPluginRecord( ) } -export async function updatePluginRecord(pluginId: string, id: string, patch: unknown) { - return apiPluginsUpdateRecord({ plugin: pluginId, id, requestBody: { patch } }) +export async function updatePluginRecord(pluginId: string, id: string, patch: unknown, token?: string) { + return withShareAuthorization(token, () => + apiPluginsUpdateRecord({ plugin: pluginId, id, requestBody: { patch } }), + ) } -export async function deletePluginRecord(pluginId: string, id: string) { - return apiPluginsDeleteRecord({ plugin: pluginId, id }) +export async function deletePluginRecord(pluginId: string, id: string, token?: string) { + return withShareAuthorization(token, () => apiPluginsDeleteRecord({ plugin: pluginId, id })) } export async function getPluginKv( diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 5cb4852c..5596c7a5 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -25,6 +25,8 @@ import { ensureRefmdThemes, REFMD_DARK_THEME, REFMD_LIGHT_THEME } from '@/featur import { registerWikiLinkCompletion } from '@/features/edit-document/lib/monaco/wiki-link-provider' import { useEditorContext } from '@/features/edit-document/model/editor-context' import { useViewContext } from '@/features/edit-document/model/view-context' +import { useDocumentEditorPlugins, type DocumentEditorApi, type DocumentEditorDocumentApi, type DocumentEditorRange, type DocumentEditorSelection } from '@/features/plugins' +import type { DocumentEditorPaneHostState } from '@/features/plugins/model/useDocumentEditorPlugins' import { loadMonacoVim } from '../lib/monaco/vim-loader' @@ -63,6 +65,11 @@ export type MarkdownEditorProps = { userName?: string userId?: string documentId: string + documentTitle?: string | null + documentType?: string | null + documentEditorPluginsEnabled?: boolean + documentEditorPanePlacement?: 'extraRight' | 'mosaic' + onDocumentEditorPaneHostChange?: (host: DocumentEditorPaneHostState | null) => void readOnly?: boolean extraRight?: React.ReactNode conflictControls?: React.ReactNode @@ -102,6 +109,11 @@ export function MarkdownEditor(props: MarkdownEditorProps) { userId, userName, documentId, + documentTitle, + documentType, + documentEditorPluginsEnabled = true, + documentEditorPanePlacement = 'extraRight', + onDocumentEditorPaneHostChange, readOnly = false, extraRight, conflictControls, @@ -178,6 +190,8 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const unregisterEditorRef = useRef void)>(null) const focusDisposableRef = useRef void }>(null) const blurDisposableRef = useRef void }>(null) + const pluginDecorationIdsRef = useRef>(new Map()) + const pluginHiddenRangeSourcesRef = useRef>(new Map()) const isThisEditorActive = useCallback(() => { const ed = editorRef.current @@ -517,6 +531,31 @@ export function MarkdownEditor(props: MarkdownEditorProps) { safeExecute('dispose monaco markdown handler', () => (anyEditor as any)?.__disposeMonacoMd?.()) safeExecute('dispose keydown handler', () => (anyEditor as any)?.__disposeKeydown?.()) safeExecute('dispose dirty tracker', () => (anyEditor as any)?.__disposeDirtyTracker?.()) + safeExecute('dispose plugin decorations', () => { + for (const ids of pluginDecorationIdsRef.current.values()) { + try { + anyEditor?.deltaDecorations(ids, []) + } catch { + /* noop */ + } + } + pluginDecorationIdsRef.current.clear() + }) + safeExecute('dispose plugin hidden ranges', () => { + const setHiddenAreas = (anyEditor as any)?.setHiddenAreas + if (typeof setHiddenAreas !== 'function') { + pluginHiddenRangeSourcesRef.current.clear() + return + } + for (const source of pluginHiddenRangeSourcesRef.current.values()) { + try { + setHiddenAreas.call(anyEditor, [], source) + } catch { + /* noop */ + } + } + pluginHiddenRangeSourcesRef.current.clear() + }) safeExecute('dispose read-only overlay', () => { if (anyEditor?.__readOnlyOverlay) { try { anyEditor.removeOverlayWidget(anyEditor.__readOnlyOverlay.widget) } catch {} @@ -770,6 +809,248 @@ export function MarkdownEditor(props: MarkdownEditorProps) { [ensureThisEditorActive, uploadFiles], ) + const documentEditorApi = useMemo(() => { + const editorInstance = editorRef.current as (monacoNs.editor.IStandaloneCodeEditor & { __monaco?: typeof monacoNs }) | null + const monacoInstance = editorInstance?.__monaco + if (!editorInstance || !monacoInstance) return null + + const toRange = (range: DocumentEditorRange) => + new monacoInstance.Range( + range.startLineNumber, + range.startColumn, + range.endLineNumber, + range.endColumn, + ) + + const toSelection = (): DocumentEditorSelection | null => { + const selection = editorInstance.getSelection() + const model = editorInstance.getModel() + if (!selection || !model) return null + return { + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn, + text: model.getValueInRange(selection), + isEmpty: selection.isEmpty(), + } + } + + const applyEdits = (edits: Array<{ range: DocumentEditorRange; text: string; forceMoveMarkers?: boolean }>) => { + if (readOnly) { + emitReadOnlyWarning() + return false + } + const nextEdits = edits + .filter((edit) => edit && edit.range) + .map((edit) => ({ + range: toRange(edit.range), + text: String(edit.text ?? ''), + forceMoveMarkers: edit.forceMoveMarkers !== false, + })) + if (!nextEdits.length) return false + const applied = editorInstance.executeEdits('refmd-plugin', nextEdits) + editorInstance.pushUndoStop() + try { + ;(editorInstance as any).__refmdUserEditIntent = true + ;(editorInstance as any).__refmdMarkDirty?.() + } catch { + /* noop */ + } + return applied + } + + const applyTextAtSelection = (text: string) => { + const selection = editorInstance.getSelection() + if (!selection) return false + return applyEdits([{ range: selection, text, forceMoveMarkers: true }]) + } + + return { + focus: () => editorInstance.focus(), + getSelection: toSelection, + setSelection: (range) => { + const next = toRange(range) + editorInstance.setSelection(next) + editorInstance.revealRangeInCenterIfOutsideViewport(next) + }, + applyEdits, + replaceSelection: applyTextAtSelection, + insertText: applyTextAtSelection, + revealLine: (line) => { + if (!Number.isFinite(line)) return + editorInstance.revealLineInCenterIfOutsideViewport(Math.max(1, Math.floor(line))) + }, + revealRange: (range) => { + editorInstance.revealRangeInCenterIfOutsideViewport(toRange(range)) + }, + getRangeFromOffset: (offset, length = 0) => { + const model = editorInstance.getModel() + if (!model) return null + const contentLength = model.getValueLength() + const startOffset = Math.max(0, Math.min(contentLength, Math.floor(offset))) + const endOffset = Math.max(startOffset, Math.min(contentLength, startOffset + Math.max(0, Math.floor(length)))) + const start = model.getPositionAt(startOffset) + const end = model.getPositionAt(endOffset) + return { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column, + } + }, + getOffsetFromPosition: (position) => { + const model = editorInstance.getModel() + if (!model) return null + const lineNumber = Math.max(1, Math.floor(position.lineNumber)) + const column = Math.max(1, Math.floor(position.column)) + try { + return model.getOffsetAt({ lineNumber, column }) + } catch { + return null + } + }, + onSelectionChange: (callback) => { + const disposable = editorInstance.onDidChangeCursorSelection(() => { + callback(toSelection()) + }) + return () => { + try { + disposable.dispose() + } catch { + /* noop */ + } + } + }, + setDecorations: (ownerId, decorations) => { + const owner = String(ownerId || 'default') + const previous = pluginDecorationIdsRef.current.get(owner) ?? [] + const nextDecorations = decorations.map((decoration) => ({ + range: toRange(decoration.range), + options: { + className: decoration.className, + inlineClassName: decoration.inlineClassName, + glyphMarginClassName: decoration.glyphMarginClassName, + hoverMessage: decoration.hoverMessage ? { value: decoration.hoverMessage } : undefined, + overviewRuler: decoration.overviewRulerColor + ? { + color: decoration.overviewRulerColor, + position: monacoInstance.editor.OverviewRulerLane.Right, + } + : undefined, + minimap: decoration.minimapColor + ? { + color: decoration.minimapColor, + position: monacoInstance.editor.MinimapPosition.Inline, + } + : undefined, + }, + })) + const nextIds = editorInstance.deltaDecorations(previous, nextDecorations) + pluginDecorationIdsRef.current.set(owner, nextIds) + return () => { + const current = pluginDecorationIdsRef.current.get(owner) + if (!current) return + try { + editorInstance.deltaDecorations(current, []) + } catch { + /* noop */ + } + pluginDecorationIdsRef.current.delete(owner) + } + }, + setHiddenRanges: (ownerId, ranges) => { + const owner = String(ownerId || 'default') + const setHiddenAreas = (editorInstance as any).setHiddenAreas + if (typeof setHiddenAreas !== 'function') { + return () => { + pluginHiddenRangeSourcesRef.current.delete(owner) + } + } + + const model = editorInstance.getModel() + if (!model) return () => {} + let source = pluginHiddenRangeSourcesRef.current.get(owner) + if (!source) { + source = {} + pluginHiddenRangeSourcesRef.current.set(owner, source) + } + + const lineCount = model.getLineCount() + const nextRanges = ranges + .map((item) => item?.range) + .filter((range): range is DocumentEditorRange => Boolean(range)) + .map((range) => { + const startLine = Math.min(lineCount, Math.max(1, Math.floor(range.startLineNumber))) + const endLine = Math.min(lineCount, Math.max(startLine, Math.floor(range.endLineNumber || startLine))) + return new monacoInstance.Range(startLine, 1, endLine, model.getLineMaxColumn(endLine)) + }) + + try { + setHiddenAreas.call(editorInstance, nextRanges, source) + } catch { + return () => {} + } + + return () => { + const current = pluginHiddenRangeSourcesRef.current.get(owner) + if (current !== source) return + try { + setHiddenAreas.call(editorInstance, [], source) + } catch { + /* noop */ + } + pluginHiddenRangeSourcesRef.current.delete(owner) + } + }, + } + }, [editorMountNonce, editorRef, emitReadOnlyWarning, readOnly]) + + const documentEditorDocument = useMemo(() => { + const ytext = doc.getText('content') + return { + id: documentId, + type: documentType ?? 'markdown', + title: documentTitle ?? null, + token: shareToken ?? null, + readOnly, + getContent: () => ytext.toString(), + setContent: (value) => { + if (readOnly) { + emitReadOnlyWarning() + return false + } + const next = String(value ?? '') + doc.transact(() => { + ytext.delete(0, ytext.length) + ytext.insert(0, next) + }) + markDocumentContentDirty(documentId, next) + return true + }, + onContentChange: (callback) => { + const observer = () => callback(ytext.toString()) + ytext.observe(observer) + return () => { + try { + ytext.unobserve(observer) + } catch { + /* noop */ + } + } + }, + } + }, [doc, documentId, documentTitle, documentType, emitReadOnlyWarning, readOnly, shareToken]) + + const pluginPanes = useDocumentEditorPlugins({ + enabled: documentEditorPluginsEnabled && !conflictView, + document: documentEditorDocument, + editor: documentEditorApi, + onPaneHostChange: onDocumentEditorPaneHostChange, + }) + + const resolvedExtraRight = extraRight ?? (documentEditorPanePlacement === 'extraRight' ? pluginPanes.extraRight : undefined) + return ( @@ -796,7 +1077,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { void): () => void + setDecorations(ownerId: string, decorations: DocumentEditorDecorationInput[]): () => void + setHiddenRanges(ownerId: string, ranges: DocumentEditorHiddenRangeInput[]): () => void +} + +export type DocumentEditorDocumentApi = { + id: string + type?: string | null + title?: string | null + token?: string | null + readOnly: boolean + getContent(): string + setContent(value: string): boolean + onContentChange(callback: (content: string) => void): () => void +} + +export type DocumentEditorUserApi = { + id: string + name: string +} + +export type DocumentEditorPaneRenderContext = { + plugin: { + id: string + version: string + manifest: ManifestItem + } + user: DocumentEditorUserApi | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + pane: { + id: string + active: boolean + close(): void + onActiveChange(callback: (active: boolean) => void): () => void + } +} + +export type DocumentEditorPaneContribution = { + id: string + title: string + order?: number + icon?: string + render( + container: HTMLElement, + ctx: DocumentEditorPaneRenderContext, + ): void | (() => void) +} + +export type DocumentEditorPaneRegistration = { + dispose(): void + setBadge(value: string | number | null): void + setTitle(title: string): void + open(): void +} + +export type DocumentEditorRecordApi = { + list(kind: string, options?: { limit?: number; offset?: number }): Promise +} + +export type DocumentEditorKvApi = { + get(key: string): Promise +} + +export type DocumentEditorActionApi = { + exec(action: string, payload?: Record): Promise +} + +export type DocumentEditorActivationContext = { + plugin: { + id: string + version: string + manifest: ManifestItem + } + user: DocumentEditorUserApi | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + documentPanes: { + register(pane: DocumentEditorPaneContribution): DocumentEditorPaneRegistration + } + records: DocumentEditorRecordApi + kv: DocumentEditorKvApi + actions: DocumentEditorActionApi + toast(level: 'info' | 'success' | 'warning' | 'error', message: string): void +} + +export type DocumentEditorPluginMatch = { + manifest: ManifestItem + module: any +} + +export type RegisteredDocumentEditorPane = { + key: string + pluginId: string + pluginVersion: string + pluginManifest: ManifestItem + id: string + title: string + order: number + icon?: string + badge: string | number | null + contribution: DocumentEditorPaneContribution +} diff --git a/app/src/features/plugins/lib/pane-icons.tsx b/app/src/features/plugins/lib/pane-icons.tsx new file mode 100644 index 00000000..27b565cc --- /dev/null +++ b/app/src/features/plugins/lib/pane-icons.tsx @@ -0,0 +1,44 @@ +"use client" + +import { + AlertTriangle, + BookOpen, + Bug, + CheckCircle2, + FileText, + List, + ListTree, + MessageSquare, + PanelRight, + Search, + Tags, + type LucideIcon, +} from 'lucide-react' +import type { ReactNode } from 'react' + +const documentPaneIcons: Record = { + 'alert-triangle': AlertTriangle, + 'book-open': BookOpen, + bug: Bug, + 'check-circle': CheckCircle2, + 'check-circle-2': CheckCircle2, + 'file-text': FileText, + list: List, + 'list-tree': ListTree, + 'message-square': MessageSquare, + 'panel-right': PanelRight, + search: Search, + tags: Tags, +} + +function normalizeDocumentPaneIconName(icon?: string | null) { + return String(icon ?? '') + .trim() + .toLowerCase() + .replace(/[_\s]+/g, '-') +} + +export function renderDocumentPaneIcon(icon?: string | null, className = 'h-4 w-4'): ReactNode { + const Icon = documentPaneIcons[normalizeDocumentPaneIconName(icon)] ?? PanelRight + return +} diff --git a/app/src/features/plugins/lib/resolution.ts b/app/src/features/plugins/lib/resolution.ts index d6cc4627..b8f432de 100644 --- a/app/src/features/plugins/lib/resolution.ts +++ b/app/src/features/plugins/lib/resolution.ts @@ -3,6 +3,7 @@ import type { DocumentHeaderAction } from '@/shared/types/document' import type { PluginManifestItem } from '@/entities/plugin' import { getPluginManifest, getPluginKv } from '@/entities/plugin' +import type { DocumentEditorPluginMatch } from './document-editor' import { createPluginHost, applyShareTokenToRoute, @@ -23,6 +24,75 @@ export type DocumentPluginMatch = { docId: string } +function hasDocumentRouteSurface(item: PluginManifestItem, mod: any) { + const mounts = Array.isArray(item.mounts) ? item.mounts.filter(Boolean) : [] + return mounts.length > 0 || typeof mod?.canOpen === 'function' || typeof mod?.getRoute === 'function' +} + +export async function resolveDocumentEditorPlugins( + options: { + docId: string + token?: string | null + workspaceId?: string | null + document?: { type?: string | null; title?: string | null; readOnly?: boolean } + }, +): Promise { + const docId = options.docId?.trim?.() ?? '' + if (!docId) return [] + const manifest = await getPluginManifestCached({ + token: options.token ?? undefined, + workspaceId: options.workspaceId ?? null, + }) + const apiOrigin = getApiOrigin() + const matches: DocumentEditorPluginMatch[] = [] + + for (const item of manifest) { + const frontend = item?.frontend as { entry?: string; mode?: string } | undefined + const entry = frontend?.entry?.trim() + if (!entry) continue + if ((frontend?.mode || 'esm').toLowerCase() !== 'esm') continue + + let mod: any + try { + mod = await loadPluginModule(item) + } catch (error) { + console.error('[plugins] failed to load document editor plugin', item?.id, error) + continue + } + if (!mod || typeof mod.activateDocumentEditor !== 'function') continue + + let canActivate = true + if (typeof mod.canActivateDocumentEditor === 'function') { + try { + canActivate = await mod.canActivateDocumentEditor({ + plugin: { + id: item.id, + version: item.version ?? '', + manifest: item, + }, + document: { + id: docId, + type: options.document?.type ?? 'markdown', + title: options.document?.title ?? null, + token: options.token ?? null, + readOnly: Boolean(options.document?.readOnly), + }, + origin: apiOrigin, + token: options.token ?? null, + }) + } catch (error) { + console.error('[plugins] failed to test document editor plugin', item?.id, error) + canActivate = false + } + } + if (!canActivate) continue + + matches.push({ manifest: item, module: mod }) + } + + return matches +} + const PLUGIN_MANIFEST_CACHE_TTL_MS = 5_000 const pluginManifestCache = new Map< string, @@ -121,6 +191,7 @@ export async function resolvePluginForDocumentById( return null } if (!mod) return null + if (!hasDocumentRouteSurface(item, mod)) return null const detectionHost = { origin: apiOrigin, @@ -218,6 +289,7 @@ export async function resolvePluginForDocument( continue } if (!mod) continue + if (!hasDocumentRouteSurface(item, mod)) continue const detectionHost = { origin: apiOrigin, diff --git a/app/src/features/plugins/lib/runtime.ts b/app/src/features/plugins/lib/runtime.ts index 773dde58..947f645b 100644 --- a/app/src/features/plugins/lib/runtime.ts +++ b/app/src/features/plugins/lib/runtime.ts @@ -486,7 +486,9 @@ async function executeHostAction( const kind = args?.kind if (typeof kind !== 'string' || !kind) throw fail('BAD_REQUEST', 'kind required') const token = (args?.token ?? ctx.token) || undefined - const response = await apiListPluginRecords(ctx.pluginId, docId, kind, token) + const limit = Number.isFinite(args?.limit) ? Number(args.limit) : undefined + const offset = Number.isFinite(args?.offset) ? Number(args.offset) : undefined + const response = await apiListPluginRecords(ctx.pluginId, docId, kind, { limit, offset }, token) return ok(response) } case 'host.kv.get': { diff --git a/app/src/features/plugins/model/useDocumentEditorPlugins.tsx b/app/src/features/plugins/model/useDocumentEditorPlugins.tsx new file mode 100644 index 00000000..273b24c0 --- /dev/null +++ b/app/src/features/plugins/model/useDocumentEditorPlugins.tsx @@ -0,0 +1,574 @@ +"use client" + +import { X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' +import { toast as sonnerToast } from 'sonner' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { ScrollArea } from '@/shared/ui/scroll-area' + +import { + execPluginAction, + getPluginKv, + listPluginRecords, +} from '@/entities/plugin' + +import { useAuthContext } from '@/features/auth' + +import type { + DocumentEditorActivationContext, + DocumentEditorApi, + DocumentEditorDocumentApi, + DocumentEditorPaneContribution, + DocumentEditorPaneRegistration, + DocumentEditorPaneRenderContext, + DocumentEditorPluginMatch, + DocumentEditorUserApi, + RegisteredDocumentEditorPane, +} from '../lib/document-editor' +import { renderDocumentPaneIcon } from '../lib/pane-icons' +import { resolveDocumentEditorPlugins } from '../lib/resolution' + +type UseDocumentEditorPluginsArgs = { + enabled: boolean + document: DocumentEditorDocumentApi | null + editor: DocumentEditorApi | null + onPaneHostChange?: (host: DocumentEditorPaneHostState | null) => void +} + +type ActiveListener = (active: boolean) => void + +function scopePluginOwnerId(pluginId: string, ownerId: string) { + return `${pluginId}:${String(ownerId || 'default')}` +} + +function createScopedDocumentEditorApi(editor: DocumentEditorApi, pluginId: string): DocumentEditorApi { + return { + ...editor, + setDecorations: (ownerId, decorations) => + editor.setDecorations(scopePluginOwnerId(pluginId, ownerId), decorations), + setHiddenRanges: (ownerId, ranges) => + editor.setHiddenRanges(scopePluginOwnerId(pluginId, ownerId), ranges), + } +} + +function createInactivePaneRegistration(): DocumentEditorPaneRegistration { + return { + dispose: () => {}, + setBadge: () => {}, + setTitle: () => {}, + open: () => {}, + } +} + +function resolveAnonymousDocumentEditorUser(): DocumentEditorUserApi | null { + if (typeof window === 'undefined') return null + try { + const keyName = 'refmd_anon_identity' + const existing = window.localStorage.getItem(keyName) + if (existing) { + const parsed = JSON.parse(existing) as Partial + if (typeof parsed.id === 'string' && typeof parsed.name === 'string') { + return { id: parsed.id, name: parsed.name } + } + } + const rnd = Math.random().toString(36).slice(2, 8) + const identity = { id: `guest:${rnd}`, name: `Guest-${rnd}` } + window.localStorage.setItem(keyName, JSON.stringify(identity)) + return identity + } catch { + const rnd = Math.random().toString(36).slice(2, 8) + return { id: `guest:${rnd}`, name: `Guest-${rnd}` } + } +} + +function applyDocumentEditorActionEffects(effects: unknown) { + if (!Array.isArray(effects)) return + for (const effect of effects) { + if (!effect || typeof effect !== 'object') continue + const item = effect as any + if (item.type === 'showToast') { + const level = typeof item.level === 'string' ? item.level : 'info' + const message = typeof item.message === 'string' ? item.message : '' + if (!message) continue + const fn = (sonnerToast as any)[level] + if (typeof fn === 'function') fn(message) + else sonnerToast(message) + continue + } + if (item.type === 'navigate' && typeof item.to === 'string' && typeof window !== 'undefined') { + window.location.href = item.to + } + } +} + +export type DocumentEditorPaneHostState = { + panes: RegisteredDocumentEditorPane[] + activePaneKey: string | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + user: DocumentEditorUserApi | null + openPane: (key: string) => void + closePane: (key?: string | null) => void + activeListenersRef: MutableRefObject>> +} + +export function useDocumentEditorPlugins({ + enabled, + document, + editor, + onPaneHostChange, +}: UseDocumentEditorPluginsArgs) { + const { activeWorkspaceId, user } = useAuthContext() + const documentEditorUser = useMemo(() => { + if (!user) return resolveAnonymousDocumentEditorUser() + return { + id: user.id, + name: user.name, + } + }, [user]) + const [panes, setPanes] = useState([]) + const [activePaneKey, setActivePaneKey] = useState(null) + const activeListenersRef = useRef>>(new Map()) + + useEffect(() => { + for (const [key, listeners] of activeListenersRef.current.entries()) { + const active = key === activePaneKey + for (const listener of listeners) { + try { + listener(active) + } catch { + /* noop */ + } + } + } + }, [activePaneKey]) + + const openPane = useCallback((key: string) => { + setActivePaneKey(key) + }, []) + + const closePane = useCallback((key?: string | null) => { + setActivePaneKey((current) => { + if (!key || current === key) return null + return current + }) + }, []) + + useEffect(() => { + if (!enabled || !document || !editor) { + setPanes([]) + setActivePaneKey(null) + return + } + + let cancelled = false + const activationDisposers: Array<() => void> = [] + const registeredKeys = new Set() + + const removePane = (key: string) => { + registeredKeys.delete(key) + activeListenersRef.current.delete(key) + setPanes((current) => current.filter((pane) => pane.key !== key)) + setActivePaneKey((current) => { + if (current !== key) return current + return null + }) + } + + const buildContext = (match: DocumentEditorPluginMatch): DocumentEditorActivationContext => { + const pluginId = String(match.manifest.id) + const pluginVersion = String(match.manifest.version ?? '') + const plugin = { + id: pluginId, + version: pluginVersion, + manifest: match.manifest, + } + const scopedEditor = createScopedDocumentEditorApi(editor, pluginId) + + const registerPane = (contribution: DocumentEditorPaneContribution) => { + const localPaneId = String(contribution?.id ?? '').trim() + if (!localPaneId) { + throw new Error('document pane id is required') + } + if (cancelled) return createInactivePaneRegistration() + const key = `${pluginId}:${localPaneId}` + const pane: RegisteredDocumentEditorPane = { + key, + pluginId, + pluginVersion, + pluginManifest: match.manifest, + id: localPaneId, + title: contribution.title || localPaneId, + order: Number.isFinite(contribution.order) ? Number(contribution.order) : 1000, + icon: contribution.icon, + badge: null, + contribution, + } + + registeredKeys.add(key) + setPanes((current) => { + const next = current.filter((item) => item.key !== key) + next.push(pane) + next.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)) + return next + }) + + return { + dispose: () => removePane(key), + setBadge: (value: string | number | null) => { + if (cancelled) return + setPanes((current) => + current.map((item) => (item.key === key ? { ...item, badge: value } : item)), + ) + }, + setTitle: (title: string) => { + if (cancelled) return + const nextTitle = String(title || localPaneId) + setPanes((current) => + current.map((item) => (item.key === key ? { ...item, title: nextTitle } : item)), + ) + }, + open: () => { + if (cancelled) return + setActivePaneKey(key) + }, + } + } + + return { + plugin, + user: documentEditorUser, + document, + editor: scopedEditor, + documentPanes: { + register: registerPane, + }, + records: { + list: async (kind, options) => { + const response = await listPluginRecords( + pluginId, + document.id, + kind, + options, + document.token ?? undefined, + ) + return Array.isArray(response) ? response : ((response as any)?.items ?? []) + }, + }, + kv: { + get: async (key) => { + const response = await getPluginKv(pluginId, document.id, key, document.token ?? undefined) + return (response as any)?.value + }, + }, + actions: { + exec: async (action, payload = {}) => { + const response = await execPluginAction( + pluginId, + action, + { ...payload, docId: document.id }, + document.token ?? undefined, + ) + applyDocumentEditorActionEffects((response as any)?.effects) + if ((response as any)?.ok === false) { + const message = String((response as any)?.error?.message || (response as any)?.error?.code || 'Plugin action failed') + throw new Error(message) + } + return (response as any)?.data ?? response + }, + }, + toast: (level, message) => { + const fn = (sonnerToast as any)[level] + if (typeof fn === 'function') fn(message) + else sonnerToast(message) + }, + } + } + + ;(async () => { + try { + const matches = await resolveDocumentEditorPlugins({ + docId: document.id, + token: document.token ?? null, + workspaceId: activeWorkspaceId ?? null, + document: { + type: document.type ?? 'markdown', + title: document.title ?? null, + readOnly: document.readOnly, + }, + }) + if (cancelled) return + + for (const match of matches) { + if (cancelled) break + try { + const dispose = await Promise.resolve( + match.module.activateDocumentEditor(buildContext(match)), + ) + if (typeof dispose === 'function') { + if (cancelled) { + try { + dispose() + } catch { + /* noop */ + } + } else { + activationDisposers.push(dispose) + } + } + } catch (error) { + console.error('[plugins] failed to activate document editor plugin', match.manifest?.id, error) + } + } + } catch (error) { + console.error('[plugins] failed to resolve document editor plugins', error) + } + })() + + return () => { + cancelled = true + for (const dispose of activationDisposers.splice(0).reverse()) { + try { + dispose() + } catch { + /* noop */ + } + } + for (const key of Array.from(registeredKeys)) { + removePane(key) + } + } + }, [activeWorkspaceId, document, documentEditorUser, editor, enabled]) + + const extraRight = useMemo(() => { + if (!panes.length || !activePaneKey || !document || !editor) return null + return ( + + ) + }, [activePaneKey, closePane, document, documentEditorUser, editor, openPane, panes]) + + const paneHost = useMemo(() => { + if (!document || !editor) return null + return { + panes, + activePaneKey, + document, + editor, + user: documentEditorUser, + openPane, + closePane, + activeListenersRef, + } + }, [activePaneKey, closePane, document, documentEditorUser, editor, openPane, panes]) + + useEffect(() => { + onPaneHostChange?.(enabled ? paneHost : null) + }, [enabled, onPaneHostChange, paneHost]) + + useEffect(() => { + return () => { + onPaneHostChange?.(null) + } + }, [onPaneHostChange]) + + return { + panes, + activePaneKey, + extraRight, + paneHost, + } +} + +export function DocumentEditorPanes({ + panes, + activePaneKey, + document, + editor, + user, + onOpenPane, + onClosePane, + activeListenersRef, +}: { + panes: RegisteredDocumentEditorPane[] + activePaneKey: string | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + user: DocumentEditorUserApi | null + onOpenPane: (key: string) => void + onClosePane: (key?: string | null) => void + activeListenersRef: MutableRefObject>> +}) { + const activePane = panes.find((pane) => pane.key === activePaneKey) ?? null + const handleActivePaneClose = useCallback(() => { + if (activePaneKey) onClosePane(activePaneKey) + }, [activePaneKey, onClosePane]) + + if (!activePane) return null + + return ( + + + + {panes.map((pane) => ( + onOpenPane(pane.key)} + title={pane.title} + > + {pane.icon ? ( + + {renderDocumentPaneIcon(pane.icon, 'h-3.5 w-3.5')} + + ) : null} + {pane.title} + {pane.badge != null && pane.badge !== '' ? ( + + {pane.badge} + + ) : null} + + ))} + + onClosePane(activePane.key)} + aria-label="Close document pane" + title="Close pane" + > + + + + + + + + + + ) +} + +function DocumentEditorPaneBody({ + pane, + document, + editor, + user, + activeListenersRef, + onClose, +}: { + pane: RegisteredDocumentEditorPane + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + user: DocumentEditorUserApi | null + activeListenersRef: MutableRefObject>> + onClose: () => void +}) { + const containerRef = useRef(null) + const { + contribution, + id: paneId, + key: paneKey, + pluginId, + pluginManifest, + pluginVersion, + } = pane + const scopedEditor = useMemo( + () => createScopedDocumentEditorApi(editor, pluginId), + [editor, pluginId], + ) + + useEffect(() => { + const container = containerRef.current + if (!container) return + container.innerHTML = '' + + const paneCtx: DocumentEditorPaneRenderContext = { + plugin: { + id: pluginId, + version: pluginVersion, + manifest: pluginManifest, + }, + user, + document, + editor: scopedEditor, + pane: { + id: paneId, + active: true, + close: onClose, + onActiveChange: (callback) => { + let listeners = activeListenersRef.current.get(paneKey) + if (!listeners) { + listeners = new Set() + activeListenersRef.current.set(paneKey, listeners) + } + listeners.add(callback) + try { + callback(true) + } catch { + /* noop */ + } + return () => { + listeners?.delete(callback) + } + }, + }, + } + + let dispose: void | (() => void) + try { + dispose = contribution.render(container, paneCtx) + } catch (error) { + console.error('[plugins] failed to render document pane', pluginId, paneId, error) + container.textContent = 'Failed to render plugin pane.' + } + + return () => { + try { + if (typeof dispose === 'function') dispose() + } catch { + /* noop */ + } + container.innerHTML = '' + } + }, [ + activeListenersRef, + contribution, + document, + onClose, + paneId, + paneKey, + pluginId, + pluginManifest, + pluginVersion, + scopedEditor, + user, + ]) + + return +} diff --git a/app/src/features/plugins/ui/SplitEditorHost.tsx b/app/src/features/plugins/ui/SplitEditorHost.tsx index 848fd7b0..c10cca90 100644 --- a/app/src/features/plugins/ui/SplitEditorHost.tsx +++ b/app/src/features/plugins/ui/SplitEditorHost.tsx @@ -360,6 +360,7 @@ function PluginSplitEditorStageInner({ docId, token, host, previewDelegate, onDo documentId={docId} readOnly={isReadOnly} extraRight={undefined} + documentEditorPluginsEnabled={false} renderPreview={renderPreview} /> )} diff --git a/app/src/shared/lib/mosaic-events.ts b/app/src/shared/lib/mosaic-events.ts index 969c2fe6..15189d48 100644 --- a/app/src/shared/lib/mosaic-events.ts +++ b/app/src/shared/lib/mosaic-events.ts @@ -1,6 +1,7 @@ export const OPEN_PREVIEW_TILE_EVENT = 'refmd:mosaic:open-preview-tile' export const OPEN_EDITOR_TILE_EVENT = 'refmd:mosaic:open-editor-tile' export const OPEN_BACKLINKS_TILE_EVENT = 'refmd:mosaic:open-backlinks-tile' +export const OPEN_DOCUMENT_PLUGIN_PANE_EVENT = 'refmd:mosaic:open-document-plugin-pane' export const MOSAIC_SCROLL_SYNC_EVENT = 'refmd:mosaic:scroll-sync' export const MOSAIC_SET_VIEW_MODE_EVENT = 'refmd:mosaic:set-view-mode' export const MOSAIC_CURRENT_VIEW_MODE_EVENT = 'refmd:mosaic:current-view-mode' @@ -46,6 +47,22 @@ export function dispatchOpenBacklinksTile(documentId: string) { ) } +export type OpenDocumentPluginPaneDetail = { + documentId: string + paneKey?: string +} + +export function dispatchOpenDocumentPluginPane(documentId: string, paneKey?: string) { + if (typeof window === 'undefined') return + const id = (documentId || '').trim() + if (!id) return + window.dispatchEvent( + new CustomEvent(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, { + detail: paneKey ? { documentId: id, paneKey } : { documentId: id }, + }), + ) +} + export type MosaicScrollSyncDetail = { groupId: string source: 'editor' | 'preview' diff --git a/app/src/widgets/document/DocumentMosaicWorkspace.tsx b/app/src/widgets/document/DocumentMosaicWorkspace.tsx index 3ece2adb..07c34597 100644 --- a/app/src/widgets/document/DocumentMosaicWorkspace.tsx +++ b/app/src/widgets/document/DocumentMosaicWorkspace.tsx @@ -20,13 +20,16 @@ import { } from 'react-mosaic-component' import { toast } from 'sonner' +import { useRealtime } from '@/shared/contexts/realtime-context' import { useShortcut } from '@/shared/hooks/use-shortcut' import { MOSAIC_SCROLL_SYNC_EVENT, OPEN_BACKLINKS_TILE_EVENT, + OPEN_DOCUMENT_PLUGIN_PANE_EVENT, OPEN_EDITOR_TILE_EVENT, OPEN_PREVIEW_TILE_EVENT, MOSAIC_SET_VIEW_MODE_EVENT, + dispatchOpenDocumentPluginPane, dispatchMosaicScrollSync, dispatchMosaicSetViewMode, dispatchMosaicCurrentViewMode, @@ -40,17 +43,30 @@ import { useAuthContext } from '@/features/auth' import { BacklinksPanel } from '@/features/document-backlinks' import { EditorOverlay, MarkdownEditor, PreviewPane, useCollaborativeDocument } from '@/features/edit-document' import { mountResolvedPlugin, resolvePluginForDocument, resolvePluginForDocumentById, type DocumentPluginMatch } from '@/features/plugins' +import { renderDocumentPaneIcon } from '@/features/plugins/lib/pane-icons' +import { DocumentEditorPanes, type DocumentEditorPaneHostState } from '@/features/plugins/model/useDocumentEditorPlugins' import { mountSplitEditorPreviewStage } from '@/features/plugins/ui/SplitEditorHost' import DocumentPage, { type DocumentLoaderData, type DocumentPageProps, type DocumentPageRenderContext } from './DocumentPage' type TileKey = `tile:${string}` -type TileMode = 'editor' | 'preview' | 'backlinks' -type TileSpec = { +type TileMode = 'editor' | 'preview' | 'backlinks' | 'plugin-pane' +type BaseTileSpec = { mode: TileMode documentId: string +} +type EditorPreviewTileSpec = BaseTileSpec & { + mode: 'editor' | 'preview' syncGroupId?: string } +type BacklinksTileSpec = BaseTileSpec & { + mode: 'backlinks' +} +type PluginPaneTileSpec = BaseTileSpec & { + mode: 'plugin-pane' + pluginPaneKey: string +} +type TileSpec = EditorPreviewTileSpec | BacklinksTileSpec | PluginPaneTileSpec type MosaicState = { layout: MosaicNode | null @@ -63,6 +79,7 @@ const STORAGE_KEY_PREFIX = 'refmd-document-mosaic-state-v3' const FORCE_FLOATING_TOC_MAX_WIDTH_PX = 1024 const EXPAND_PERCENTAGE = 80 const UNEXPAND_PERCENTAGE = 50 +const DOCUMENT_PLUGIN_PANE_SPLIT_PERCENTAGE = 72 const PLUGIN_USES_SPLIT_EDITOR_EVENT = 'refmd:plugin:uses-split-editor' const splitCapablePluginDocIds = new Set() @@ -150,7 +167,7 @@ function tileControlsToggle(key = 'more') { ) } -function TileCloseButton() { +function TileCloseButton({ onBeforeClose }: { onBeforeClose?: () => void } = {}) { const mosaic = useContext(MosaicContext) const mosaicWindow = useContext(MosaicWindowContext) @@ -161,6 +178,7 @@ function TileCloseButton() { aria-label="Close tile" title="Close tile" onClick={() => { + onBeforeClose?.() try { mosaic.mosaicActions.remove(mosaicWindow.mosaicWindowActions.getPath()) } catch { @@ -244,6 +262,16 @@ function insertLeafAtRight(layout: MosaicNode | null, leaf: TileKey): M return { direction: 'row', first: layout, second: leaf, splitPercentage: 50 } } +function insertDocumentPluginPaneAtRight(layout: MosaicNode | null, leaf: TileKey): MosaicNode { + if (!layout) return leaf + return { + direction: 'row', + first: layout, + second: leaf, + splitPercentage: DOCUMENT_PLUGIN_PANE_SPLIT_PERCENTAGE, + } +} + type InsertSplitMode = 'auto' | 'row' | 'column' function insertLeafWithMode( @@ -567,8 +595,10 @@ function sanitizeState(state: MosaicState, activeDocumentId: string): MosaicStat } const nextSpec: TileSpec = { ...spec, documentId: trimmedId } - if (trimmedGroupId) nextSpec.syncGroupId = trimmedGroupId - else delete (nextSpec as any).syncGroupId + if (nextSpec.mode === 'editor' || nextSpec.mode === 'preview') { + if (trimmedGroupId) nextSpec.syncGroupId = trimmedGroupId + else delete (nextSpec as any).syncGroupId + } nextTiles[leaf] = nextSpec } } @@ -593,8 +623,22 @@ function sanitizeState(state: MosaicState, activeDocumentId: string): MosaicStat for (const [, bucket] of byDoc) { if (bucket.editors.length === 0 || bucket.previews.length === 0) continue - const editorGroups = new Set(bucket.editors.map((key) => nextTiles[key]?.syncGroupId).filter(Boolean) as string[]) - const previewGroups = new Set(bucket.previews.map((key) => nextTiles[key]?.syncGroupId).filter(Boolean) as string[]) + const editorGroups = new Set( + bucket.editors + .map((key) => { + const spec = nextTiles[key] + return spec?.mode === 'editor' ? spec.syncGroupId : undefined + }) + .filter(Boolean) as string[], + ) + const previewGroups = new Set( + bucket.previews + .map((key) => { + const spec = nextTiles[key] + return spec?.mode === 'preview' ? spec.syncGroupId : undefined + }) + .filter(Boolean) as string[], + ) let groupId: string | null = null for (const candidate of editorGroups) { @@ -611,6 +655,7 @@ function sanitizeState(state: MosaicState, activeDocumentId: string): MosaicStat for (const key of [...bucket.editors, ...bucket.previews]) { const spec = nextTiles[key] if (!spec) continue + if (spec.mode !== 'editor' && spec.mode !== 'preview') continue if (spec.syncGroupId !== groupId) { nextTiles[key] = { ...spec, syncGroupId: groupId } needsSyncUpdate = true @@ -636,6 +681,28 @@ function sanitizeState(state: MosaicState, activeDocumentId: string): MosaicStat return { layout: prunedLayout, tiles: nextTiles } } +function sameDocumentEditorPaneHost(a: DocumentEditorPaneHostState | null | undefined, b: DocumentEditorPaneHostState | null | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.document !== b.document) return false + if (a.editor !== b.editor) return false + if ((a.user?.id ?? null) !== (b.user?.id ?? null)) return false + if ((a.user?.name ?? null) !== (b.user?.name ?? null)) return false + if (a.activePaneKey !== b.activePaneKey) return false + if (a.openPane !== b.openPane || a.closePane !== b.closePane) return false + if (a.panes.length !== b.panes.length) return false + return a.panes.every((pane, index) => { + const next = b.panes[index] + return Boolean( + next && + pane.key === next.key && + pane.title === next.title && + pane.icon === next.icon && + pane.badge === next.badge, + ) + }) +} + function loadState(activeDocumentId: string, storageKey: string): MosaicState { if (typeof window === 'undefined') return defaultState(activeDocumentId) try { @@ -669,6 +736,7 @@ type Props = Pick { @@ -679,6 +747,7 @@ export default function DocumentMosaicWorkspace(props: Props) { const [mosaicState, setMosaicState] = useState(() => { return mosaicStorageKey ? loadState(id, mosaicStorageKey) : defaultState(id) }) + const [documentPaneHosts, setDocumentPaneHosts] = useState>({}) const [activeDocumentId, setActiveDocumentId] = useState(id) const activeDocumentIdRef = useRef(activeDocumentId) const activeTileRef = useRef<{ tileKey: TileKey; documentId: string; mode: TileMode } | null>(null) @@ -703,6 +772,7 @@ export default function DocumentMosaicWorkspace(props: Props) { const lastReportedViewModeRef = useRef<{ docId: string; mode: 'editor' | 'split' | 'preview' } | null>(null) const lastRouteDocIdRef = useRef(id) const lastSeenRouteDocIdRef = useRef(id) + const pluginPaneActionPrefix = 'document-plugin-pane:' useEffect(() => { insertSplitModeRef.current = insertSplitMode @@ -884,7 +954,7 @@ export default function DocumentMosaicWorkspace(props: Props) { } if (candidates.length === 0) return - const modeRank: Record = { editor: 0, preview: 1, backlinks: 2 } + const modeRank: Record = { editor: 0, preview: 1, backlinks: 2, 'plugin-pane': 3 } candidates.sort((a, b) => (modeRank[a.spec.mode] ?? 9) - (modeRank[b.spec.mode] ?? 9)) const picked = candidates[0] if (!picked) return @@ -1069,6 +1139,7 @@ export default function DocumentMosaicWorkspace(props: Props) { const clearSync = (key: TileKey) => { const spec = nextTiles[key] if (!spec) return + if (spec.mode !== 'editor' && spec.mode !== 'preview') return if (!spec.syncGroupId) return nextTiles[key] = { ...spec, syncGroupId: undefined } } @@ -1551,6 +1622,114 @@ export default function DocumentMosaicWorkspace(props: Props) { [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], ) + const handleDocumentEditorPaneHostChange = useCallback( + (documentId: string, host: DocumentEditorPaneHostState | null) => { + const target = documentId.trim() + if (!target) return + + setDocumentPaneHosts((prev) => { + if (!host || !host.panes.length) { + if (!prev[target]) return prev + const next = { ...prev } + delete next[target] + return next + } + if (sameDocumentEditorPaneHost(prev[target], host)) return prev + return { ...prev, [target]: host } + }) + + const activePaneKey = host?.activePaneKey ?? null + const hasActivePane = Boolean(activePaneKey && host?.panes.some((pane) => pane.key === activePaneKey)) + + setMosaicState((prev) => { + const safe = sanitizeState(prev, id) + const entries = Object.entries(safe.tiles) as Array<[TileKey, TileSpec]> + const existing = entries.find( + ([, spec]) => spec.documentId === target && spec.mode === 'plugin-pane', + ) + + if (!hasActivePane || !activePaneKey) { + if (!existing) return safe + const nextTiles = { ...safe.tiles } + let nextLayout = safe.layout + for (const [key, spec] of entries) { + if (spec.documentId !== target || spec.mode !== 'plugin-pane') continue + delete nextTiles[key] + nextLayout = removeLeaf(nextLayout, key) + } + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + } + + if (existing) { + const [key, spec] = existing + if (spec.mode === 'plugin-pane' && spec.pluginPaneKey === activePaneKey) return safe + return sanitizeState( + { + ...safe, + tiles: { + ...safe.tiles, + [key]: { mode: 'plugin-pane', documentId: target, pluginPaneKey: activePaneKey }, + }, + }, + id, + ) + } + + if (isSingleDocShare) return safe + if (!canAccessSharedDocument(target)) return safe + + const tileKey = makeTileKey() + const nextLayout = insertDocumentPluginPaneAtRight(safe.layout, tileKey) + const nextTiles: Record = { + ...safe.tiles, + [tileKey]: { mode: 'plugin-pane', documentId: target, pluginPaneKey: activePaneKey }, + } + expandedTileKeyRef.current = null + return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) + }) + }, + [canAccessSharedDocument, id, isSingleDocShare], + ) + + useEffect(() => { + const host = documentPaneHosts[activeDocumentId] ?? null + const panes = host?.panes ?? [] + const paneKeys = new Set(panes.map((pane) => `${pluginPaneActionPrefix}${activeDocumentId}:${pane.key}`)) + const currentActions = documentActions ?? [] + let next = currentActions.filter((action) => { + if (!action.id?.startsWith(pluginPaneActionPrefix)) return true + return paneKeys.has(action.id) + }) + + for (const pane of panes) { + const actionId = `${pluginPaneActionPrefix}${activeDocumentId}:${pane.key}` + const action = { + id: actionId, + label: pane.title, + icon: renderDocumentPaneIcon(pane.icon), + tooltip: `Open ${pane.title}`, + onSelect: () => { + dispatchOpenDocumentPluginPane(activeDocumentId, pane.key) + }, + } + const existing = next.find((item) => item.id === actionId) + if (!existing) { + next = [...next, action] + continue + } + if (existing.label !== action.label || existing.tooltip !== action.tooltip) { + next = next.map((item) => (item.id === actionId ? action : item)) + } + } + + if ( + next.length !== currentActions.length || + next.some((action, index) => action !== currentActions[index]) + ) { + setDocumentActions(next) + } + }, [activeDocumentId, documentActions, documentPaneHosts, setDocumentActions]) + useEffect(() => { if (isSingleDocShare) return const handler = (event: Event) => { @@ -1587,6 +1766,23 @@ export default function DocumentMosaicWorkspace(props: Props) { return () => window.removeEventListener(OPEN_BACKLINKS_TILE_EVENT, handler as EventListener) }, [addBacklinksTile, isSingleDocShare]) + useEffect(() => { + if (isSingleDocShare) return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string; paneKey?: string }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + if (!documentId) return + const host = documentPaneHosts[documentId] + if (!host || !host.panes.length) return + const requested = typeof detail?.paneKey === 'string' ? detail.paneKey : '' + const pane = host.panes.find((item) => item.key === requested) ?? host.panes[0] + if (!pane) return + host.openPane(pane.key) + } + window.addEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) + return () => window.removeEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) + }, [documentPaneHosts, isSingleDocShare]) + useEffect(() => { if (typeof window === 'undefined') return const handler = (event: Event) => { @@ -1613,6 +1809,8 @@ export default function DocumentMosaicWorkspace(props: Props) { mosaicState={mosaicState} setMosaicState={setMosaicState} addPreviewTile={addPreviewTile} + documentPaneHosts={documentPaneHosts} + onDocumentEditorPaneHostChange={handleDocumentEditorPaneHostChange} insertSplitMode={insertSplitMode} isSingleDocShare={isSingleDocShare} onCloseAllTiles={closeAllTilesToDashboard} @@ -1878,6 +2076,8 @@ function DocumentMosaicBody({ mosaicState, setMosaicState, addPreviewTile, + documentPaneHosts, + onDocumentEditorPaneHostChange, insertSplitMode, isSingleDocShare, onCloseAllTiles, @@ -1889,6 +2089,8 @@ function DocumentMosaicBody({ mosaicState: MosaicState setMosaicState: Dispatch> addPreviewTile: (documentId: string) => void + documentPaneHosts: Record + onDocumentEditorPaneHostChange: (documentId: string, host: DocumentEditorPaneHostState | null) => void insertSplitMode: InsertSplitMode isSingleDocShare: boolean onCloseAllTiles: () => void @@ -1897,11 +2099,12 @@ function DocumentMosaicBody({ expandedTileKeyRef: { current: TileKey | null } }) { const setTileMode = useCallback( - (tileKey: TileKey, mode: TileMode) => { + (tileKey: TileKey, mode: 'editor' | 'preview') => { setMosaicState((prev) => { const safe = sanitizeState(prev, ctx.id) const spec = safe.tiles[tileKey] if (!spec) return safe + if (spec.mode !== 'editor' && spec.mode !== 'preview') return safe const nextTiles: Record = {} for (const [key, value] of Object.entries(safe.tiles) as Array<[TileKey, TileSpec]>) { if (key === tileKey) nextTiles[key] = { ...value, mode, syncGroupId: undefined } @@ -2005,6 +2208,7 @@ function DocumentMosaicBody({ onToggleExpand={() => onToggleExpandTile(tileId)} onSwitchToPreview={() => setTileMode(tileId, 'preview')} isSingleDocShare={isSingleDocShare} + onDocumentEditorPaneHostChange={onDocumentEditorPaneHostChange} onActivate={() => onActivateDocument(docId, tileId, 'editor')} /> ) @@ -2022,6 +2226,19 @@ function DocumentMosaicBody({ ) } + if (spec.mode === 'plugin-pane') { + return ( + onToggleExpandTile(tileId)} + onActivate={() => onActivateDocument(docId, tileId, 'plugin-pane')} + /> + ) + } + return ( void + onActivate?: () => void +}) { + const activePaneKey = + host?.panes.some((pane) => pane.key === paneKey) ? paneKey : (host?.activePaneKey ?? null) + + useEffect(() => { + if (!host || !activePaneKey || host.activePaneKey === activePaneKey) return + host.openPane(activePaneKey) + }, [activePaneKey, host]) + + return ( + + path={path} + title="" + toolbarControls={[ + , + + + , + { + host?.closePane(activePaneKey) + }} + />, + tileControlsToggle(), + ]} + > + + + {host && activePaneKey ? ( + + ) : ( + + Plugin pane is not available for this document. + + )} + + + + ) +} + function useEditorIdentity() { const { user } = useAuthContext() const anonIdentity = useMemo(() => { @@ -2415,6 +2710,7 @@ function EditorTile({ onToggleExpand, onSwitchToPreview, isSingleDocShare, + onDocumentEditorPaneHostChange, onActivate, }: { tileKey: TileKey @@ -2427,6 +2723,7 @@ function EditorTile({ onToggleExpand: () => void onSwitchToPreview: () => void isSingleDocShare: boolean + onDocumentEditorPaneHostChange: (documentId: string, host: DocumentEditorPaneHostState | null) => void onActivate?: () => void }) { const pluginLookup = useCreatedByPluginId(documentId, ctx.shareToken ?? null) @@ -2511,6 +2808,7 @@ function EditorTile({ scrollSyncGroupId={scrollSyncGroupId} isFocusedDocument={isFocusedDocument} ctx={ctx} + onDocumentEditorPaneHostChange={onDocumentEditorPaneHostChange} /> @@ -2524,14 +2822,22 @@ function MarkdownEditorTileBody({ scrollSyncGroupId, isFocusedDocument, ctx, + onDocumentEditorPaneHostChange, }: { tileKey: TileKey documentId: string scrollSyncGroupId?: string | null isFocusedDocument: boolean ctx: DocumentPageRenderContext + onDocumentEditorPaneHostChange: (documentId: string, host: DocumentEditorPaneHostState | null) => void }) { const identity = useEditorIdentity() + const handlePaneHostChange = useCallback( + (host: DocumentEditorPaneHostState | null) => { + onDocumentEditorPaneHostChange(documentId, host) + }, + [documentId, onDocumentEditorPaneHostChange], + ) const localSession = useCollaborativeDocument(documentId, ctx.shareToken, { contributeToRealtimeContext: false, useUrlShareTokenFallback: false, @@ -2554,6 +2860,8 @@ function MarkdownEditorTileBody({ forcedView="editor" embedded scrollSyncGroupId={scrollSyncGroupId ?? null} + documentEditorPanePlacement="mosaic" + onDocumentEditorPaneHostChange={handlePaneHostChange} /> ) } @@ -2572,6 +2880,10 @@ function MarkdownEditorTileBody({ userId={identity.userId} userName={identity.userName} documentId={documentId} + documentTitle={null} + documentType="markdown" + documentEditorPanePlacement="mosaic" + onDocumentEditorPaneHostChange={handlePaneHostChange} readOnly={localSession.isReadOnly} /> ) diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index 3b5daac4..d7e459e2 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -6,6 +6,10 @@ import { toast } from 'sonner' import { ApiError, type GitPullConflictItem, type GitPullResolution } from '@/shared/api' import { useRealtime } from '@/shared/contexts/realtime-context' +import { + OPEN_DOCUMENT_PLUGIN_PANE_EVENT, + dispatchOpenDocumentPluginPane, +} from '@/shared/lib/mosaic-events' import type { DocumentHeaderAction } from '@/shared/types/document' import { Button } from '@/shared/ui/button' @@ -24,6 +28,8 @@ import { EditorOverlay, MarkdownEditor, useCollaborativeDocument } from '@/featu import type { PreviewPaneProps } from '@/features/edit-document/ui/PreviewPane' import { setConflicts as setGlobalConflicts, readResolutions, setResolutions, clearResolutions, readSessionId, setSessionId, clearSession, readConflicts, subscribeSessionId } from '@/features/git-sync/lib/git-conflict-store' import { performPullSession } from '@/features/git-sync/lib/pull-session-manager' +import { renderDocumentPaneIcon } from '@/features/plugins/lib/pane-icons' +import type { DocumentEditorPaneHostState } from '@/features/plugins/model/useDocumentEditorPlugins' import { PluginDocumentMount } from '@/features/plugins/ui/PluginDocumentMount' export type DocumentLoaderData = { @@ -79,6 +85,8 @@ const genHunkId = () => { return Math.random().toString(36).slice(2) } +const documentPluginPaneActionPrefix = 'document-plugin-pane:' + const buildLineDiffSegments = (oursRaw: string, theirsRaw: string): { segments: ConflictSegments; hunks: ConflictHunk[] } => { const ours = oursRaw.split('\n') const theirs = theirsRaw.split('\n') @@ -268,6 +276,7 @@ function DocumentClient({ const qc = useQueryClient() const { user } = useAuthContext() const { documentTitle: realtimeTitle, documentActions, setDocumentActions, documentPluginId } = useRealtime() + const handlesDocumentPluginPanes = !render const pluginIdHintFromLoader = typeof loaderData?.createdByPlugin === 'string' ? loaderData.createdByPlugin.trim() : '' const pluginIdHintFromRealtime = typeof documentPluginId === 'string' ? documentPluginId.trim() : '' const pluginIdHint = pluginIdHintFromLoader || pluginIdHintFromRealtime @@ -285,6 +294,8 @@ function DocumentClient({ const [hunkChoices, setHunkChoices] = useState>({}) const [hunkDefaultSide, setHunkDefaultSide] = useState<'ours' | 'theirs'>('ours') const [hunkAnchors, setHunkAnchors] = useState>([]) + const documentPaneHostRef = useRef(null) + const [documentPanes, setDocumentPanes] = useState([]) const lastPayloadRef = useRef([]) const { status, doc, awareness, isReadOnly, error: realtimeError } = useCollaborativeDocument(id, shareToken) const hasDoc = Boolean(doc) @@ -293,6 +304,85 @@ function DocumentClient({ const unsubscribe = subscribeSessionId((sid) => setSessionIdState(sid)) return () => unsubscribe() }, []) + + const handleDocumentPaneHostChange = useCallback((host: DocumentEditorPaneHostState | null) => { + documentPaneHostRef.current = host + const nextPanes = host?.panes ?? [] + setDocumentPanes((current) => { + if ( + current.length === nextPanes.length && + current.every((pane, index) => { + const next = nextPanes[index] + return ( + next && + pane.key === next.key && + pane.title === next.title && + pane.icon === next.icon && + pane.badge === next.badge + ) + }) + ) { + return current + } + return nextPanes + }) + }, []) + + useEffect(() => { + if (!handlesDocumentPluginPanes) return + const panes = documentPanes + const paneKeys = new Set(panes.map((pane) => `${documentPluginPaneActionPrefix}${id}:${pane.key}`)) + const currentActions = documentActions ?? [] + let next = currentActions.filter((action) => { + if (!action.id?.startsWith(documentPluginPaneActionPrefix)) return true + return paneKeys.has(action.id) + }) + + for (const pane of panes) { + const actionId = `${documentPluginPaneActionPrefix}${id}:${pane.key}` + const action = { + id: actionId, + label: pane.title, + icon: renderDocumentPaneIcon(pane.icon), + tooltip: `Open ${pane.title}`, + onSelect: () => { + dispatchOpenDocumentPluginPane(id, pane.key) + }, + } + const existing = next.find((item) => item.id === actionId) + if (!existing) { + next = [...next, action] + continue + } + if (existing.label !== action.label || existing.tooltip !== action.tooltip) { + next = next.map((item) => (item.id === actionId ? action : item)) + } + } + + if ( + next.length !== currentActions.length || + next.some((action, index) => action !== currentActions[index]) + ) { + setDocumentActions(next) + } + }, [documentActions, documentPanes, handlesDocumentPluginPanes, id, setDocumentActions]) + + useEffect(() => { + if (!handlesDocumentPluginPanes) return + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ documentId?: string; paneKey?: string }>).detail + const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' + if (documentId !== id) return + const host = documentPaneHostRef.current + if (!host || !host.panes.length) return + const requested = typeof detail?.paneKey === 'string' ? detail.paneKey : '' + const pane = host.panes.find((item) => item.key === requested) ?? host.panes[0] + if (!pane) return + host.openPane(pane.key) + } + window.addEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) + return () => window.removeEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) + }, [handlesDocumentPluginPanes, id]) const anonIdentity = useMemo(() => { if (user) return null try { @@ -788,12 +878,15 @@ function DocumentClient({ userId: user?.id || anonIdentity?.id, userName: user?.name || anonIdentity?.name, documentId: id, + documentTitle: resolvedTitle || loaderData?.title || null, + documentType: 'markdown', readOnly: isReadOnly || Boolean(activeConflict), conflictView, conflictHunkWidgets, conflictBadgeText, conflictControls, previewOverride: previewOverrideValue, + onDocumentEditorPaneHostChange: handlesDocumentPluginPanes ? handleDocumentPaneHostChange : undefined, extraRight: undefined, renderPreview: usePluginPreview ? renderPluginPreview : undefined, } satisfies Parameters[0]) diff --git a/app/src/widgets/temporary/TemporaryDocumentPage.tsx b/app/src/widgets/temporary/TemporaryDocumentPage.tsx index c9267dae..4d240f8f 100644 --- a/app/src/widgets/temporary/TemporaryDocumentPage.tsx +++ b/app/src/widgets/temporary/TemporaryDocumentPage.tsx @@ -141,6 +141,7 @@ export default function TemporaryDocumentPage({ tempId }: Props) { connected={false} initialView="editor" documentId={tempId} + documentEditorPluginsEnabled={false} readOnly={false} /> )}