From ce14ae54edd0e7f72b807571c96961edd84e861e Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 4 Jun 2026 14:51:45 +0900 Subject: [PATCH 1/5] feat: editor pane plugin api --- app/src/entities/plugin/api/index.ts | 10 +- app/src/features/edit-document/ui/Editor.tsx | 282 ++++++++++- .../edit-document/ui/EditorLayout.tsx | 6 + app/src/features/plugins/index.ts | 18 + .../features/plugins/lib/document-editor.ts | 143 ++++++ app/src/features/plugins/lib/resolution.ts | 72 +++ app/src/features/plugins/lib/runtime.ts | 25 + .../model/useDocumentEditorPlugins.tsx | 436 ++++++++++++++++++ .../features/plugins/ui/SplitEditorHost.tsx | 1 + app/src/shared/lib/mosaic-events.ts | 17 + .../document/DocumentMosaicWorkspace.tsx | 326 ++++++++++++- app/src/widgets/document/DocumentPage.tsx | 93 +++- .../temporary/TemporaryDocumentPage.tsx | 1 + 13 files changed, 1413 insertions(+), 17 deletions(-) create mode 100644 app/src/features/plugins/lib/document-editor.ts create mode 100644 app/src/features/plugins/model/useDocumentEditorPlugins.tsx diff --git a/app/src/entities/plugin/api/index.ts b/app/src/entities/plugin/api/index.ts index 4a336ad7..c482c847 100644 --- a/app/src/entities/plugin/api/index.ts +++ b/app/src/entities/plugin/api/index.ts @@ -76,12 +76,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..55097b4d 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -25,6 +25,7 @@ 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 DocumentEditorPaneHostState, type DocumentEditorRange, type DocumentEditorSelection } from '@/features/plugins' import { loadMonacoVim } from '../lib/monaco/vim-loader' @@ -63,6 +64,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 +108,11 @@ export function MarkdownEditor(props: MarkdownEditorProps) { userId, userName, documentId, + documentTitle, + documentType, + documentEditorPluginsEnabled = true, + documentEditorPanePlacement = 'extraRight', + onDocumentEditorPaneHostChange, readOnly = false, extraRight, conflictControls, @@ -178,6 +189,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 +530,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 +808,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 +1076,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 DocumentEditorPaneRenderContext = { + plugin: { + id: string + version: string + manifest: ManifestItem + } + 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 + create(kind: string, data: unknown): Promise + update(id: string, patch: unknown): Promise + delete(id: string): Promise +} + +export type DocumentEditorKvApi = { + get(key: string): Promise + put(key: string, value: unknown): Promise +} + +export type DocumentEditorActivationContext = { + plugin: { + id: string + version: string + manifest: ManifestItem + } + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + documentPanes: { + register(pane: DocumentEditorPaneContribution): DocumentEditorPaneRegistration + } + records: DocumentEditorRecordApi + kv: DocumentEditorKvApi + 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/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..3932d12f 100644 --- a/app/src/features/plugins/lib/runtime.ts +++ b/app/src/features/plugins/lib/runtime.ts @@ -13,10 +13,13 @@ import { import type { DocumentHeaderAction } from '@/shared/types/document' import { + createPluginRecord as apiCreatePluginRecord, + deletePluginRecord as apiDeletePluginRecord, execPluginAction as apiExecPluginAction, getPluginKv as apiGetPluginKv, listPluginRecords as apiListPluginRecords, putPluginKv as apiPutPluginKv, + updatePluginRecord as apiUpdatePluginRecord, } from '@/entities/plugin/api' import { @@ -489,6 +492,28 @@ async function executeHostAction( const response = await apiListPluginRecords(ctx.pluginId, docId, kind, token) return ok(response) } + case 'host.records.create': { + const docId = ensureDocId(args?.docId) + 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 apiCreatePluginRecord(ctx.pluginId, docId, kind, args?.data ?? null, token) + return ok(response) + } + case 'host.records.update': { + const id = args?.id + if (typeof id !== 'string' || !id) throw fail('BAD_REQUEST', 'record id required') + const token = (args?.token ?? ctx.token) || undefined + const response = await apiUpdatePluginRecord(ctx.pluginId, id, args?.patch ?? {}, token) + return ok(response) + } + case 'host.records.delete': { + const id = args?.id + if (typeof id !== 'string' || !id) throw fail('BAD_REQUEST', 'record id required') + const token = (args?.token ?? ctx.token) || undefined + await apiDeletePluginRecord(ctx.pluginId, id, token) + return ok({}) + } case 'host.kv.get': { const docId = ensureDocId(args?.docId) const key = args?.key diff --git a/app/src/features/plugins/model/useDocumentEditorPlugins.tsx b/app/src/features/plugins/model/useDocumentEditorPlugins.tsx new file mode 100644 index 00000000..257d016e --- /dev/null +++ b/app/src/features/plugins/model/useDocumentEditorPlugins.tsx @@ -0,0 +1,436 @@ +"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 { + createPluginRecord, + deletePluginRecord, + getPluginKv, + listPluginRecords, + putPluginKv, + updatePluginRecord, +} from '@/entities/plugin' + +import { useAuthContext } from '@/features/auth' + +import type { + DocumentEditorActivationContext, + DocumentEditorApi, + DocumentEditorDocumentApi, + DocumentEditorPaneContribution, + DocumentEditorPaneRenderContext, + DocumentEditorPluginMatch, + RegisteredDocumentEditorPane, +} from '../lib/document-editor' +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 + +export type DocumentEditorPaneHostState = { + panes: RegisteredDocumentEditorPane[] + activePaneKey: string | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + openPane: (key: string) => void + closePane: (key?: string | null) => void + activeListenersRef: MutableRefObject>> +} + +export function useDocumentEditorPlugins({ + enabled, + document, + editor, + onPaneHostChange, +}: UseDocumentEditorPluginsArgs) { + const { activeWorkspaceId } = useAuthContext() + 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 registerPane = (contribution: DocumentEditorPaneContribution) => { + const localPaneId = String(contribution?.id ?? '').trim() + if (!localPaneId) { + throw new Error('document pane id is required') + } + 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) => { + setPanes((current) => + current.map((item) => (item.key === key ? { ...item, badge: value } : item)), + ) + }, + setTitle: (title: string) => { + const nextTitle = String(title || localPaneId) + setPanes((current) => + current.map((item) => (item.key === key ? { ...item, title: nextTitle } : item)), + ) + }, + open: () => setActivePaneKey(key), + } + } + + return { + plugin, + document, + editor, + documentPanes: { + register: registerPane, + }, + records: { + list: async (kind) => { + const response = await listPluginRecords(pluginId, document.id, kind, document.token ?? undefined) + return Array.isArray(response) ? response : ((response as any)?.items ?? []) + }, + create: (kind, data) => + createPluginRecord(pluginId, document.id, kind, data, document.token ?? undefined), + update: (id, patch) => + updatePluginRecord(pluginId, id, patch, document.token ?? undefined), + delete: (id) => + deletePluginRecord(pluginId, id, document.token ?? undefined), + }, + kv: { + get: (key) => getPluginKv(pluginId, document.id, key, document.token ?? undefined), + put: (key, value) => putPluginKv(pluginId, document.id, key, value, document.token ?? undefined), + }, + 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, editor, enabled]) + + const extraRight = useMemo(() => { + if (!panes.length || !activePaneKey || !document || !editor) return null + return ( + + ) + }, [activePaneKey, closePane, document, editor, openPane, panes]) + + const paneHost = useMemo(() => { + if (!document || !editor) return null + return { + panes, + activePaneKey, + document, + editor, + openPane, + closePane, + activeListenersRef, + } + }, [activePaneKey, closePane, document, 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, + onOpenPane, + onClosePane, + activeListenersRef, +}: { + panes: RegisteredDocumentEditorPane[] + activePaneKey: string | null + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + onOpenPane: (key: string) => void + onClosePane: (key?: string | null) => void + activeListenersRef: MutableRefObject>> +}) { + const activePane = panes.find((pane) => pane.key === activePaneKey) ?? null + + if (!activePane) return null + + return ( +
+
+
+ {panes.map((pane) => ( + + ))} +
+ +
+ +
+ onClosePane(activePane.key)} + /> +
+
+
+ ) +} + +function DocumentEditorPaneBody({ + pane, + document, + editor, + activeListenersRef, + onClose, +}: { + pane: RegisteredDocumentEditorPane + document: DocumentEditorDocumentApi + editor: DocumentEditorApi + activeListenersRef: MutableRefObject>> + onClose: () => void +}) { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + container.innerHTML = '' + + const paneCtx: DocumentEditorPaneRenderContext = { + plugin: { + id: pane.pluginId, + version: pane.pluginVersion, + manifest: pane.pluginManifest, + }, + document, + editor, + pane: { + id: pane.id, + active: true, + close: onClose, + onActiveChange: (callback) => { + let listeners = activeListenersRef.current.get(pane.key) + if (!listeners) { + listeners = new Set() + activeListenersRef.current.set(pane.key, listeners) + } + listeners.add(callback) + try { + callback(true) + } catch { + /* noop */ + } + return () => { + listeners?.delete(callback) + } + }, + }, + } + + let dispose: void | (() => void) + try { + dispose = pane.contribution.render(container, paneCtx) + } catch (error) { + console.error('[plugins] failed to render document pane', pane.pluginId, pane.id, error) + container.textContent = 'Failed to render plugin pane.' + } + + return () => { + try { + if (typeof dispose === 'function') dispose() + } catch { + /* noop */ + } + container.innerHTML = '' + } + }, [activeListenersRef, document, editor, onClose, pane]) + + 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..55087f6b 100644 --- a/app/src/widgets/document/DocumentMosaicWorkspace.tsx +++ b/app/src/widgets/document/DocumentMosaicWorkspace.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { Columns2, Eye, FileCode, Loader2, Maximize2, MoreHorizontal, X } from 'lucide-react' +import { Columns2, Eye, FileCode, Loader2, Maximize2, MessageSquare, MoreHorizontal, X } from 'lucide-react' import { useCallback, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { Mosaic, @@ -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, @@ -39,18 +42,29 @@ import { browseShare } from '@/entities/share' 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 { DocumentEditorPanes, mountResolvedPlugin, resolvePluginForDocument, resolvePluginForDocumentById, type DocumentEditorPaneHostState, type DocumentPluginMatch } from '@/features/plugins' 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 +77,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 +165,7 @@ function tileControlsToggle(key = 'more') { ) } -function TileCloseButton() { +function TileCloseButton({ onBeforeClose }: { onBeforeClose?: () => void } = {}) { const mosaic = useContext(MosaicContext) const mosaicWindow = useContext(MosaicWindowContext) @@ -161,6 +176,7 @@ function TileCloseButton() { aria-label="Close tile" title="Close tile" onClick={() => { + onBeforeClose?.() try { mosaic.mosaicActions.remove(mosaicWindow.mosaicWindowActions.getPath()) } catch { @@ -244,6 +260,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 +593,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 +621,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 +653,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 +679,23 @@ 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.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.badge === next.badge, + ) + }) +} + function loadState(activeDocumentId: string, storageKey: string): MosaicState { if (typeof window === 'undefined') return defaultState(activeDocumentId) try { @@ -669,6 +729,7 @@ type Props = Pick { @@ -679,6 +740,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 +765,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 +947,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 +1132,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 +1615,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: , + 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 +1759,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 +1802,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 +2069,8 @@ function DocumentMosaicBody({ mosaicState, setMosaicState, addPreviewTile, + documentPaneHosts, + onDocumentEditorPaneHostChange, insertSplitMode, isSingleDocShare, onCloseAllTiles, @@ -1889,6 +2082,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 +2092,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 +2201,7 @@ function DocumentMosaicBody({ onToggleExpand={() => onToggleExpandTile(tileId)} onSwitchToPreview={() => setTileMode(tileId, 'preview')} isSingleDocShare={isSingleDocShare} + onDocumentEditorPaneHostChange={onDocumentEditorPaneHostChange} onActivate={() => onActivateDocument(docId, tileId, 'editor')} /> ) @@ -2022,6 +2219,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 +2702,7 @@ function EditorTile({ onToggleExpand, onSwitchToPreview, isSingleDocShare, + onDocumentEditorPaneHostChange, onActivate, }: { tileKey: TileKey @@ -2427,6 +2715,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 +2800,7 @@ function EditorTile({ scrollSyncGroupId={scrollSyncGroupId} isFocusedDocument={isFocusedDocument} ctx={ctx} + onDocumentEditorPaneHostChange={onDocumentEditorPaneHostChange} />
@@ -2524,14 +2814,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 +2852,8 @@ function MarkdownEditorTileBody({ forcedView="editor" embedded scrollSyncGroupId={scrollSyncGroupId ?? null} + documentEditorPanePlacement="mosaic" + onDocumentEditorPaneHostChange={handlePaneHostChange} /> ) } @@ -2572,6 +2872,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..8a4ec42b 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -1,11 +1,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { BookmarkPlus, Download, History } from 'lucide-react' +import { BookmarkPlus, Download, History, MessageSquare } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' 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,7 @@ 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 type { DocumentEditorPaneHostState } from '@/features/plugins' import { PluginDocumentMount } from '@/features/plugins/ui/PluginDocumentMount' export type DocumentLoaderData = { @@ -79,6 +84,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 +275,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 +293,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 +303,84 @@ 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.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: , + 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 +876,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} /> )} From 6260313d9e993bcece06f68eba09ffef1969a7ed Mon Sep 17 00:00:00 2001 From: munenick Date: Fri, 5 Jun 2026 11:24:40 +0900 Subject: [PATCH 2/5] fix: harden editor pane plugin api --- app/src/entities/plugin/api/index.ts | 17 ++++- app/src/features/plugins/index.ts | 1 + app/src/features/plugins/lib/pane-icons.tsx | 45 +++++++++++++ app/src/features/plugins/lib/runtime.ts | 4 +- .../model/useDocumentEditorPlugins.tsx | 64 +++++++++++++++++-- .../document/DocumentMosaicWorkspace.tsx | 6 +- app/src/widgets/document/DocumentPage.tsx | 6 +- 7 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 app/src/features/plugins/lib/pane-icons.tsx diff --git a/app/src/entities/plugin/api/index.ts b/app/src/entities/plugin/api/index.ts index c482c847..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( diff --git a/app/src/features/plugins/index.ts b/app/src/features/plugins/index.ts index 3158c5ac..776d551b 100644 --- a/app/src/features/plugins/index.ts +++ b/app/src/features/plugins/index.ts @@ -11,6 +11,7 @@ export { mountResolvedPlugin, mountRoutePlugin, } from '@/features/plugins/lib/resolution' +export { renderDocumentPaneIcon } from '@/features/plugins/lib/pane-icons' export type { RoutePluginMatch, DocumentPluginMatch } from '@/features/plugins/lib/resolution' export type { 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..7f9ccc00 --- /dev/null +++ b/app/src/features/plugins/lib/pane-icons.tsx @@ -0,0 +1,45 @@ +"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, + comments: MessageSquare, + '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