From c4758cbdb153e6405dce687ca629ce8205e933b4 Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sun, 9 Aug 2026 21:46:21 -0500 Subject: [PATCH 1/5] feat: unified NoteTypeProvider view descriptor with NoteViewRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend NoteTypeProvider in packages/core with optional UI capability fields (supportedModes, sourceProtected, supportsScrollSync, viewComponent, toolbarItems, contextMenuBuilder) so built-in note types and plugins share a single registration pattern. Key changes: - Add NoteViewMode, NoteTypeToolbarItem, and UI fields to NoteTypeProvider in packages/core/src/contracts.ts (fields typed as unknown to keep core React-free) - New NoteViewRegistry in packages/core — runtime map of note-type IDs to full NoteTypeProvider; disposer-based register/unregister - Each built-in note-type package exports registerBuiltinNoteView(registry) mirroring the plugin registration pattern - packages/editor/src/markdown-note-view.ts provides the markdown built-in registration (avoids circular dep: core must not import React components) - packages/editor/src/note-view-descriptor.ts narrows opaque unknown fields to typed React ComponentType/ReactNode/NoteViewContextMenuBuilder - PluginContext and PluginHost gain registerNoteView(); PluginManager wires it through buildContext() alongside registerFileHandler() - use-plugins.ts creates a stable NoteViewRegistry, registers all built-ins at hook init, and exposes it via PluginsApi/AppServices - markdown-editor.tsx replaces hardcoded NOTE_RENDERERS map with registry lookup; applies sourceProtected, supportedModes, supportsScrollSync from the active descriptor - ModeToggle accepts supportedModes and supportsScrollSync props to filter visible mode buttons and conditionally show the scroll-sync toggle - Context-menu builder from descriptor seeds noteViewCtxBuilder on type change All 156 tests pass; typecheck clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/src/app.tsx | 2 + apps/web/src/components/mode-toggle.tsx | 11 ++- apps/web/src/components/note-editor.tsx | 23 +++++- apps/web/src/state/app-services.tsx | 4 + apps/web/src/state/use-plugins.ts | 28 +++++++ packages/core/src/contracts.ts | 51 +++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/markdown-note-type.ts | 5 ++ packages/core/src/note-view-registry.ts | 34 +++++++++ packages/editor/src/index.ts | 2 + packages/editor/src/markdown-editor.tsx | 73 ++++++++----------- packages/editor/src/markdown-note-view.ts | 16 ++++ packages/editor/src/note-view-descriptor.ts | 36 +++++++++ packages/note-boards/src/board-note-type.ts | 12 ++- .../note-calendar/src/calendar-note-type.ts | 12 ++- packages/note-canvas/src/canvas-note-type.ts | 12 ++- packages/note-grid/src/grid-note-type.ts | 12 ++- .../note-mermaid/src/mermaid-note-type.ts | 12 ++- packages/note-tables/src/table-note-type.ts | 12 ++- packages/plugin-host/src/context.ts | 8 ++ .../plugin-host/src/plugin-manager.test.ts | 3 + packages/plugin-host/src/plugin-manager.ts | 5 ++ 22 files changed, 320 insertions(+), 54 deletions(-) create mode 100644 packages/core/src/note-view-registry.ts create mode 100644 packages/editor/src/markdown-note-view.ts create mode 100644 packages/editor/src/note-view-descriptor.ts diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index affc098..06ef5f4 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -837,6 +837,7 @@ export function App() { setActiveDocument: (doc: { path: string; content: string; type: string } | null) => plugins.documentSignal.set(doc), fileHandlers: plugins.fileHandlers, + noteViewRegistry: plugins.noteViewRegistry, settings: settingsProps, undoableFileOps, }), @@ -855,6 +856,7 @@ export function App() { noteTypes, plugins.documentSignal, plugins.fileHandlers, + plugins.noteViewRegistry, settingsProps, undoableFileOps, ], diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx index 1a8b7e2..7a9b993 100644 --- a/apps/web/src/components/mode-toggle.tsx +++ b/apps/web/src/components/mode-toggle.tsx @@ -25,11 +25,18 @@ export function ModeToggle(props: { splitScrollSync: boolean; onToggleSyncScroll: () => void; saveState: SaveState; + /** Which editor modes are available for the current note type. Defaults to all three. */ + supportedModes?: EditorMode[]; + /** Whether to show the scroll sync toggle in split mode. Defaults to true. */ + supportsScrollSync?: boolean; }) { + const visibleModes = props.supportedModes ?? EDITOR_MODES; + const showSyncToggle = props.supportsScrollSync ?? true; + return (
- {EDITOR_MODES.map((mode) => ( + {visibleModes.map((mode) => (
- {props.mode === "split" && ( + {showSyncToggle && props.mode === "split" && (
diff --git a/apps/web/src/state/use-plugins.ts b/apps/web/src/state/use-plugins.ts index 81d8b18..c69c184 100644 --- a/apps/web/src/state/use-plugins.ts +++ b/apps/web/src/state/use-plugins.ts @@ -9,15 +9,8 @@ import { type PluginInfo, type StatusBarItem, } from "@notes/plugin-host"; -import { NoteViewRegistry } from "@notes/core"; -import { registerMarkdownNoteView } from "@notes/editor"; -import { registerBuiltinNoteView as registerCanvasNoteView } from "@notes/note-canvas"; -import { registerBuiltinNoteView as registerBoardNoteView } from "@notes/note-boards"; -import { registerBuiltinNoteView as registerTableNoteView } from "@notes/note-tables"; -import { registerBuiltinNoteView as registerMermaidNoteView } from "@notes/note-mermaid"; -import { registerBuiltinNoteView as registerCalendarNoteView } from "@notes/note-calendar"; -import { registerBuiltinNoteView as registerGridNoteView } from "@notes/note-grid"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type NoteViewRegistry } from "@notes/core"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { localPlugins } from "../plugins"; @@ -26,7 +19,6 @@ export interface PluginsApi { pluginCommands: PluginCommand[]; statusItems: StatusBarItem[]; fileHandlers: FileTypeHandler[]; - noteViewRegistry: NoteViewRegistry; list: PluginInfo[]; isEnabled: (id: string) => boolean; toggle: (id: string, enabled: boolean) => void; @@ -56,8 +48,8 @@ async function loadTomePlugin(id: string): Promise { } } -export function usePlugins(): PluginsApi { - const documentSignal = useMemo(() => new Signal(null), []); +export function usePlugins(noteViewRegistry: NoteViewRegistry): PluginsApi { + const documentSignal = useState(() => new Signal(null))[0]; const [pluginCommands, setPluginCommands] = useState([]); const [statusItems, setStatusItems] = useState([]); const [fileHandlers, setFileHandlers] = useState([]); @@ -65,21 +57,6 @@ export function usePlugins(): PluginsApi { const [tomePluginsPath, setTomePluginsPath] = useState(""); const managerRef = useRef(undefined); - // The NoteViewRegistry is stable for the lifetime of the hook instance. - // Built-in note types are registered once; plugins may register/unregister - // via PluginContext.registerNoteView → host.registerNoteView. - const noteViewRegistry = useMemo(() => { - const registry = new NoteViewRegistry(); - registerMarkdownNoteView(registry); - registerCanvasNoteView(registry); - registerBoardNoteView(registry); - registerTableNoteView(registry); - registerMermaidNoteView(registry); - registerCalendarNoteView(registry); - registerGridNoteView(registry); - return registry; - }, []); - useEffect(() => { let manager: PluginManager | undefined; let disposed = false; @@ -166,7 +143,7 @@ export function usePlugins(): PluginsApi { setStatusItems([]); setFileHandlers([]); }; - }, [documentSignal]); + }, [documentSignal, noteViewRegistry]); const toggle = useCallback((id: string, enabled: boolean) => { const manager = managerRef.current; @@ -182,7 +159,6 @@ export function usePlugins(): PluginsApi { pluginCommands, statusItems, fileHandlers, - noteViewRegistry, list, isEnabled, toggle, diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 045aded..5ff76d9 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -2,6 +2,7 @@ export * from "./types"; export * from "./markdown-editor"; export * from "./markdown-note-view"; export * from "./note-view-descriptor"; +export * from "./use-pane-sync"; export * from "./rendered-editor"; export * from "./toolbar"; export * from "./note-toolbar"; diff --git a/packages/editor/src/markdown-editor.tsx b/packages/editor/src/markdown-editor.tsx index 9a3a51e..089cded 100644 --- a/packages/editor/src/markdown-editor.tsx +++ b/packages/editor/src/markdown-editor.tsx @@ -1,29 +1,31 @@ -import { MARKDOWN_NOTE_TYPE_ID } from "@notes/core"; -import { CANVAS_NOTE_TYPE_ID } from "@notes/note-canvas"; +/** + * MarkdownEditor — a self-contained inline markdown component. + * + * This component is used as a lightweight inline editor wherever a markdown + * snippet needs to be rendered or edited in place (e.g. board cards, calendar + * event descriptions). It does NOT handle file I/O, registry routing, or the + * full note-editor lifecycle — those concerns live in NoteEditor in apps/web. + * + * Props summary: + * - value / onChange — the raw markdown string + * - mode — "edit" | "split" | "rendered" + * - callbacks — optional EditorCallbacks for wikilinks, embeds, etc. + * - viewState / onViewStateChange — cursor/scroll/focus persistence + */ import type { NoteViewContextMenuBuilder } from "@notes/ui"; -import { api } from "@notes/web/src/api/client"; -import { EmbedWidget } from "@notes/web/src/components/embed-widget"; -import { frontmatterType } from "@notes/web/src/lib/frontmatter"; -import { - importedFilePath, - markdownForImportedFile, - normalizeMediaDirectory, - toBase64, -} from "@notes/web/src/lib/images"; -import { useWorkspace } from "@notes/web/src/state/app-context"; -import { useAppServices } from "@notes/web/src/state/app-services"; -import { useToasts } from "@notes/web/src/state/toast"; -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from "react"; +import { RenderedEditor } from "./rendered-editor"; import { NativeSourceEditor } from "./native-source-editor"; import { EditorToolbar } from "./toolbar"; -import type { - CursorRequest, - EditorCallbacks, - EditorMode, - FocusRequest, - ScrollRequest, +import { + EDITOR_MODES, + type CursorRequest, + type EditorCallbacks, + type EditorMode, + type FocusRequest, + type ScrollRequest, } from "./types"; -import { getNoteViewComponent } from "./note-view-descriptor"; +import { useCursorSync, useFocusSync, useScrollSync } from "./use-pane-sync"; export type MarkdownPane = "source" | "rendered"; @@ -54,9 +56,9 @@ interface MarkdownEditorProps { viewState?: MarkdownViewState; onViewStateChange?: (patch: Partial) => void; syncSplitScroll?: boolean; - /** When true, disables all file/note drop-and-paste operations. */ isStandalone?: boolean; isReadOnly?: boolean; + callbacks?: EditorCallbacks; setNoteViewCtxBuilder?: Dispatch>; } @@ -65,7 +67,7 @@ export interface RendererProps { value: string; onChange: (markdown: string) => void; callbacks?: EditorCallbacks; - isStandalone?: boolean; // If the file belongs to the tome or not + isStandalone?: boolean; cursorRequest?: CursorRequest; scrollRequest?: ScrollRequest; onCursorChange?: (position: number) => void; @@ -75,17 +77,15 @@ export interface RendererProps { onRegisterContextMenu?: Dispatch>; } -function basename(path: string): string { - return (path.split("/").pop() ?? path).replace(/\.[^.]+$/, ""); -} - /** - * Hybrid markdown editor. A single `value` (markdown) drives both a source view and a TipTap - * rendered view; in split mode both are shown and stay in sync through the shared value. + * Lightweight inline markdown editor. Use for embedding a markdown view + * inside other components (board cards, modals, etc.). + * + * For the full note-editor experience with file I/O, registry routing, and + * save state, use `NoteEditor` in apps/web instead. */ export function MarkdownEditor({ mode, - path, value, onChange, disableToolbarInEdit = false, @@ -93,102 +93,85 @@ export function MarkdownEditor({ onViewStateChange, syncSplitScroll = true, isStandalone = false, - isReadOnly, - setNoteViewCtxBuilder, + isReadOnly = false, + callbacks, }: MarkdownEditorProps) { - const { dispatch } = useWorkspace(); - const { settings, noteViewRegistry } = useAppServices(); - const { notify } = useToasts(); - - const sourceCursorRef = useRef( + const sourceCursor = useCursorSync( viewState?.sourceCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceCursor, ); - const renderedCursorRef = useRef( + const renderedCursor = useCursorSync( viewState?.renderedCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedCursor, ); + const sourceScroll = useScrollSync( + viewState?.sourceScrollRatio ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceScrollRatio, + ); + const renderedScroll = useScrollSync( + viewState?.renderedScrollRatio ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedScrollRatio, + ); + const sourceFocus = useFocusSync(); + const renderedFocus = useFocusSync(); + const activePaneRef = useRef( viewState?.lastFocusedPane ?? DEFAULT_MARKDOWN_VIEW_STATE.lastFocusedPane, ); - const tokenRef = useRef(3); - const syncLockRef = useRef(null); + const sourceCursorPosRef = useRef( + viewState?.sourceCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceCursor, + ); + const renderedCursorPosRef = useRef( + viewState?.renderedCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedCursor, + ); const prevModeRef = useRef(mode); - - const [sourceCursorRequest, setSourceCursorRequest] = useState({ - token: 1, - position: viewState?.sourceCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceCursor, - }); - const [renderedCursorRequest, setRenderedCursorRequest] = useState({ - token: 1, - position: viewState?.renderedCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedCursor, - }); - const [sourceScrollRequest, setSourceScrollRequest] = useState({ - token: 1, - ratio: viewState?.sourceScrollRatio ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceScrollRatio, - }); - const [renderedScrollRequest, setRenderedScrollRequest] = useState({ - token: 1, - ratio: viewState?.renderedScrollRatio ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedScrollRatio, - }); - const [sourceFocusRequest, setSourceFocusRequest] = useState({ token: 1 }); - const [renderedFocusRequest, setRenderedFocusRequest] = useState({ token: 1 }); - - const nextToken = () => { - const token = tokenRef.current; - tokenRef.current += 1; - return token; - }; + // Scroll-sync lock refs — prevent feedback loops when programmatically scrolling. + const sourceScrollLock = useRef(false); + const renderedScrollLock = useRef(false); const emitViewState = useCallback( - (patch: Partial) => { - onViewStateChange?.(patch); - }, + (patch: Partial) => onViewStateChange?.(patch), [onViewStateChange], ); - const requestPaneFocus = useCallback((pane: MarkdownPane) => { - if (pane === "source") { - setSourceFocusRequest({ token: nextToken() }); - } else { - setRenderedFocusRequest({ token: nextToken() }); - } - }, []); - const preferredPaneForMode = useCallback((nextMode: EditorMode): MarkdownPane => { - if (nextMode === "edit") { - return "source"; - } - if (nextMode === "rendered") { - return "rendered"; - } + if (nextMode === "edit") return "source"; + if (nextMode === "rendered") return "rendered"; return activePaneRef.current; }, []); + const requestPaneFocus = useCallback( + (pane: MarkdownPane) => { + if (pane === "source") sourceFocus.send(); + else renderedFocus.send(); + }, + [sourceFocus, renderedFocus], + ); + useEffect(() => { requestPaneFocus(preferredPaneForMode(mode)); }, [mode, preferredPaneForMode, requestPaneFocus]); useEffect(() => { const prev = prevModeRef.current; - if (prev === mode) { - return; - } + if (prev === mode) return; if (mode === "edit") { const position = - activePaneRef.current === "rendered" ? renderedCursorRef.current : sourceCursorRef.current; - setSourceCursorRequest({ token: nextToken(), position }); + activePaneRef.current === "rendered" + ? renderedCursorPosRef.current + : sourceCursorPosRef.current; + sourceCursor.send(position); requestPaneFocus("source"); } else if (mode === "rendered") { const position = - activePaneRef.current === "source" ? sourceCursorRef.current : renderedCursorRef.current; - setRenderedCursorRequest({ token: nextToken(), position }); + activePaneRef.current === "source" + ? sourceCursorPosRef.current + : renderedCursorPosRef.current; + renderedCursor.send(position); requestPaneFocus("rendered"); } else { requestPaneFocus(preferredPaneForMode(mode)); } prevModeRef.current = mode; - }, [mode, preferredPaneForMode, requestPaneFocus]); + }, [mode, preferredPaneForMode, requestPaneFocus, sourceCursor, renderedCursor]); const handleSourceFocus = useCallback(() => { activePaneRef.current = "source"; @@ -202,7 +185,7 @@ export function MarkdownEditor({ const handleSourceCursorChange = useCallback( (position: number) => { - sourceCursorRef.current = position; + sourceCursorPosRef.current = position; emitViewState({ sourceCursor: position }); }, [emitViewState], @@ -210,7 +193,7 @@ export function MarkdownEditor({ const handleRenderedCursorChange = useCallback( (position: number) => { - renderedCursorRef.current = position; + renderedCursorPosRef.current = position; emitViewState({ renderedCursor: position }); }, [emitViewState], @@ -219,90 +202,33 @@ export function MarkdownEditor({ const handleSourceScrollChange = useCallback( (ratio: number) => { emitViewState({ sourceScrollRatio: ratio }); - if (!syncSplitScroll || mode !== "split") { + if (!syncSplitScroll || mode !== "split") return; + if (sourceScrollLock.current) { + sourceScrollLock.current = false; return; } - if (syncLockRef.current === "source") { - syncLockRef.current = null; - return; - } - syncLockRef.current = "rendered"; - setRenderedScrollRequest({ token: nextToken(), ratio }); + renderedScrollLock.current = true; + renderedScroll.send(ratio); }, - [emitViewState, mode, syncSplitScroll], + [emitViewState, mode, syncSplitScroll, renderedScroll], ); const handleRenderedScrollChange = useCallback( (ratio: number) => { emitViewState({ renderedScrollRatio: ratio }); - if (!syncSplitScroll || mode !== "split") { + if (!syncSplitScroll || mode !== "split") return; + if (renderedScrollLock.current) { + renderedScrollLock.current = false; return; } - if (syncLockRef.current === "rendered") { - syncLockRef.current = null; - return; - } - syncLockRef.current = "source"; - setSourceScrollRequest({ token: nextToken(), ratio }); + sourceScrollLock.current = true; + sourceScroll.send(ratio); }, - [emitViewState, mode, syncSplitScroll], + [emitViewState, mode, syncSplitScroll, sourceScroll], ); - const callbacks = useMemo( - () => ({ - onOpenWikilink: (name) => { - void (async () => { - const resolved = await api.resolve(name); - if (resolved.path) { - dispatch({ type: "openFile", path: resolved.path, title: name }); - return; - } - const newPath = `${name}.md`; - await api.create(newPath, `# ${name}\n\n`).catch(() => undefined); - dispatch({ type: "openFile", path: newPath, title: name }); - })(); - }, - onOpenFile: (path) => dispatch({ type: "openFile", path: path, title: basename(path) }), - listNotes: async () => (await api.notes()).notes, - listTags: async () => (await api.tags()).tags.map((tag) => tag.tag), - onImportFile: isStandalone - ? undefined - : async (file) => { - const mediaPath = importedFilePath( - file, - normalizeMediaDirectory(settings.mediaDirectory), - ); - try { - const bytes = new Uint8Array(await file.arrayBuffer()); - await api.createBinary(mediaPath, toBase64(bytes)); - notify(`Imported file saved to ${mediaPath}`, { kind: "success" }); - return markdownForImportedFile(mediaPath, file.type, api.fileRawUrl(mediaPath)); - } catch { - notify("Couldn't import dropped file", { kind: "error" }); - return null; - } - }, - renderEmbed: (embedTarget) => , - disableFileDrop: isStandalone, - }), - [dispatch, notify, settings.mediaDirectory, isStandalone], - ); - const isCanvas = path?.toLowerCase().endsWith(".canvas"); - const frontType = isCanvas - ? CANVAS_NOTE_TYPE_ID - : (frontmatterType(value) ?? MARKDOWN_NOTE_TYPE_ID); - const activeProvider = - noteViewRegistry.get(frontType) ?? noteViewRegistry.get(MARKDOWN_NOTE_TYPE_ID); - const NoteRenderer = activeProvider ? getNoteViewComponent(activeProvider) : undefined; - - const sourceProtected = activeProvider?.sourceProtected ?? false; - const supportsScrollSync = activeProvider?.supportsScrollSync ?? false; - const supportedModes = - activeProvider?.supportedModes ?? (["edit", "split", "rendered"] as EditorMode[]); - - // Clamp the requested mode to what the active note type supports. + const supportedModes = EDITOR_MODES; const effectiveMode = supportedModes.includes(mode) ? mode : (supportedModes[0] ?? "rendered"); - const showSource = effectiveMode === "edit" || effectiveMode === "split"; const showRendered = effectiveMode === "rendered" || effectiveMode === "split"; @@ -314,36 +240,31 @@ export function MarkdownEditor({
{} : onChange} + onChange={isReadOnly ? () => {} : onChange} callbacks={callbacks} - focusRequest={sourceFocusRequest} + focusRequest={sourceFocus.request} onFocus={handleSourceFocus} - scrollRequest={supportsScrollSync ? sourceScrollRequest : undefined} - onScrollChange={supportsScrollSync ? handleSourceScrollChange : undefined} - cursorRequest={sourceCursorRequest} + scrollRequest={sourceScroll.request} + onScrollChange={handleSourceScrollChange} + cursorRequest={sourceCursor.request} onCursorChange={handleSourceCursorChange} />
)} - {showRendered && NoteRenderer && ( - )} - {showRendered && !NoteRenderer && ( -
No renderer registered for "{frontType}".
- )} ); diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index 044f498..419bd7b 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -1,6 +1,8 @@ +import type { NoteViewMode } from "@notes/core"; import type { ReactNode } from "react"; -export type EditorMode = "edit" | "split" | "rendered"; +/** Canonical editor mode — re-exported from @notes/core as NoteViewMode so there is one definition. */ +export type EditorMode = NoteViewMode; export const EDITOR_MODES: EditorMode[] = ["edit", "split", "rendered"]; diff --git a/packages/editor/src/use-pane-sync.ts b/packages/editor/src/use-pane-sync.ts new file mode 100644 index 0000000..bc7de86 --- /dev/null +++ b/packages/editor/src/use-pane-sync.ts @@ -0,0 +1,88 @@ +/** + * Pane synchronisation hooks for the split editor. + * + * All three sync types (cursor, scroll, focus) share the same token-based + * request pattern: a state object with a `token` that increments on each + * new request, letting receiving panes distinguish a new request from a + * repeated render via `useEffect([…, request.token])`. + * + * `usePaneSync` is the generic base. `useCursorSync`, `useScrollSync`, and + * `useFocusSync` are the three specializations used by the editor shell and + * return the concrete request types consumed by `RendererProps`. + */ +import { useCallback, useState } from "react"; +import type { CursorRequest, ScrollRequest, FocusRequest } from "./types"; + +// ── Generic base ────────────────────────────────────────────────────────────── + +/** Incrementing token that distinguishes new requests from repeated renders. */ +let globalToken = 1; +function nextToken(): number { + return globalToken++; +} + +export interface SyncRequest { + token: number; + value: T; +} + +/** + * Generic hook for one direction of pane synchronisation. + * `initialValue` seeds the first request. + */ +export function usePaneSync(initialValue: T): { + request: SyncRequest; + send: (value: T) => void; +} { + const [request, setRequest] = useState>({ token: 0, value: initialValue }); + const send = useCallback((value: T) => setRequest({ token: nextToken(), value }), []); + return { request, send }; +} + +// ── Cursor sync ─────────────────────────────────────────────────────────────── + +export interface CursorSyncResult { + /** Deliver to the receiving pane as the `cursorRequest` prop. */ + request: CursorRequest; + /** Call with the new cursor position to send a request to the other pane. */ + send: (position: number) => void; +} + +/** Manages cursor-position synchronisation from one pane to another. */ +export function useCursorSync(initialPosition = 0): CursorSyncResult { + const [request, setRequest] = useState({ token: 0, position: initialPosition }); + const send = useCallback((position: number) => setRequest({ token: nextToken(), position }), []); + return { request, send }; +} + +// ── Scroll sync ─────────────────────────────────────────────────────────────── + +export interface ScrollSyncResult { + /** Deliver to the receiving pane as the `scrollRequest` prop. */ + request: ScrollRequest; + /** Call with a scroll ratio (0–1) to send a request to the other pane. */ + send: (ratio: number) => void; +} + +/** Manages scroll-ratio synchronisation from one pane to another. */ +export function useScrollSync(initialRatio = 0): ScrollSyncResult { + const [request, setRequest] = useState({ token: 0, ratio: initialRatio }); + const send = useCallback((ratio: number) => setRequest({ token: nextToken(), ratio }), []); + return { request, send }; +} + +// ── Focus sync ──────────────────────────────────────────────────────────────── + +export interface FocusSyncResult { + /** Deliver to the receiving pane as the `focusRequest` prop. */ + request: FocusRequest; + /** Call to request that the receiving pane is focused. */ + send: () => void; +} + +/** Manages focus requests from one pane to another. */ +export function useFocusSync(initialToken = 0): FocusSyncResult { + const [request, setRequest] = useState({ token: initialToken }); + const send = useCallback(() => setRequest({ token: nextToken() }), []); + return { request, send }; +} diff --git a/packages/plugin-host/src/plugin-manager.test.ts b/packages/plugin-host/src/plugin-manager.test.ts index 0ad42b0..a0aecc8 100644 --- a/packages/plugin-host/src/plugin-manager.test.ts +++ b/packages/plugin-host/src/plugin-manager.test.ts @@ -32,7 +32,7 @@ function makeHost() { fileHandlers.push(handler); return () => fileHandlers.splice(fileHandlers.indexOf(handler), 1); }, - registerNoteView: (_descriptor) => { + registerNoteView: () => { return () => {}; }, document: new Signal(null), From 28a298e24fb4ad16fcb58ce6ae30b41fc75b3aaf Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Mon, 10 Aug 2026 00:18:29 -0500 Subject: [PATCH 3/5] refactor: remove MarkdownEditor component; add PaneSyncContext and useEditorCallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## MarkdownEditor removed Board cards and calendar event-details now use RenderedEditor directly (they only ever rendered in 'rendered' mode — MarkdownEditor was pure overhead). The MarkdownEditor component is deleted; its types migrate to types.ts: - MarkdownPane, MarkdownViewState, DEFAULT_MARKDOWN_VIEW_STATE - RendererProps (sync props removed — now come from PaneSyncContext) markdown-editor.tsx is emptied; removed from editor/index.ts. ## useEditorCallbacks hook (apps/web/src/state/use-editor-callbacks.tsx) Extracts the EditorCallbacks construction (wikilink navigation, file imports, embed rendering, tag/note listing) out of NoteEditor into a dedicated hook. NoteEditor now calls useEditorCallbacks(isStandalone). ## PaneSyncContext (packages/editor/src/pane-sync-context.tsx) A React context that carries the full pane-sync state so components no longer need sync props drilled through them: - PaneSyncProvider — wraps source + rendered panes in NoteEditor - useSourcePaneSync() — NativeSourceEditor calls this for cursor/scroll/ focus requests, handlers, callbacks, isReadOnly - useRenderedPaneSync() — RenderedEditor calls this for the same Both hooks return undefined outside a provider (graceful standalone use). ## Updated consumers - NativeSourceEditor: drops all sync/callbacks props; calls useSourcePaneSync() and merges context over prop fallbacks. Props kept optional for backward compat. - RenderedEditor: drops all sync props from RenderedEditorProps; calls useRenderedPaneSync() and merges context over props. - NoteEditor: wraps markdown panes in PaneSyncProvider; passes only value/onChange/path/isStandalone/callbacks/onRegisterContextMenu to DynamicNoteRenderer (sync no longer prop-drilled). All 156 tests pass; typecheck clean; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/src/components/note-editor.tsx | 154 ++++------ apps/web/src/state/use-editor-callbacks.tsx | 69 +++++ packages/editor/src/index.ts | 2 +- packages/editor/src/markdown-editor.tsx | 271 ------------------ packages/editor/src/native-source-editor.tsx | 50 ++-- packages/editor/src/note-view-descriptor.ts | 2 +- packages/editor/src/pane-sync-context.tsx | 81 ++++++ packages/editor/src/rendered-editor.tsx | 28 +- packages/editor/src/types.ts | 44 +++ packages/note-boards/src/board-card-modal.tsx | 8 +- packages/note-boards/src/board-card.tsx | 5 +- packages/note-calendar/src/event-details.tsx | 5 +- 12 files changed, 313 insertions(+), 406 deletions(-) create mode 100644 apps/web/src/state/use-editor-callbacks.tsx delete mode 100644 packages/editor/src/markdown-editor.tsx create mode 100644 packages/editor/src/pane-sync-context.tsx diff --git a/apps/web/src/components/note-editor.tsx b/apps/web/src/components/note-editor.tsx index 55366ec..ed545aa 100644 --- a/apps/web/src/components/note-editor.tsx +++ b/apps/web/src/components/note-editor.tsx @@ -3,12 +3,13 @@ import { DEFAULT_MARKDOWN_VIEW_STATE, EDITOR_MODES, EditorToolbar, - type EditorCallbacks, type EditorMode, type MarkdownViewState, type MarkdownPane, NativeSourceEditor, NoteToolbar, + PaneSyncProvider, + type PaneSyncContextValue, getNoteContextMenuBuilder, getNoteViewComponent, type RendererProps, @@ -36,17 +37,10 @@ import { import { api } from "../api/client"; import { queueWrite } from "../api/offline-queue"; import { connectTomeChanges } from "../api/ws"; -import { EmbedWidget } from "./embed-widget"; import { frontmatterType } from "../lib/frontmatter"; -import { - importedFilePath, - isImagePath, - markdownForImportedFile, - normalizeMediaDirectory, - toBase64, -} from "../lib/images"; -import { useWorkspace } from "../state/app-context"; +import { isImagePath } from "../lib/images"; import { useAppServices } from "../state/app-services"; +import { useEditorCallbacks } from "../state/use-editor-callbacks"; import { useToasts } from "../state/toast"; import { ModeToggle, SaveState } from "./mode-toggle"; @@ -131,10 +125,9 @@ export function NoteEditor({ /** When true, disables file drop/paste and frontmatter type detection. */ isStandalone?: boolean; }) { - const { markModified, setActiveDocument, fileHandlers, noteViewRegistry, settings } = - useAppServices(); - const { dispatch } = useWorkspace(); + const { markModified, setActiveDocument, fileHandlers, noteViewRegistry } = useAppServices(); const { notify } = useToasts(); + const callbacks = useEditorCallbacks(isStandalone); const stateKey = editorStateKey(path); const initialSession = markdownSessionByPath.get(stateKey) ?? { mode: defaultMode, @@ -297,49 +290,6 @@ export function NoteEditor({ [emitViewState, mode, splitScrollSync, sourceScroll], ); - // ── Callbacks for the note renderer / source editor ─────────────────────── - - const callbacks = useMemo( - () => ({ - onOpenWikilink: (name) => { - void (async () => { - const resolved = await api.resolve(name); - if (resolved.path) { - dispatch({ type: "openFile", path: resolved.path, title: name }); - return; - } - const newPath = `${name}.md`; - await api.create(newPath, `# ${name}\n\n`).catch(() => undefined); - dispatch({ type: "openFile", path: newPath, title: name }); - })(); - }, - onOpenFile: (filePath) => - dispatch({ type: "openFile", path: filePath, title: basename(filePath) }), - listNotes: async () => (await api.notes()).notes, - listTags: async () => (await api.tags()).tags.map((tag) => tag.tag), - onImportFile: isStandalone - ? undefined - : async (file) => { - const mediaPath = importedFilePath( - file, - normalizeMediaDirectory(settings.mediaDirectory), - ); - try { - const bytes = new Uint8Array(await file.arrayBuffer()); - await api.createBinary(mediaPath, toBase64(bytes)); - notify(`Imported file saved to ${mediaPath}`, { kind: "success" }); - return markdownForImportedFile(mediaPath, file.type, api.fileRawUrl(mediaPath)); - } catch { - notify("Couldn't import dropped file", { kind: "error" }); - return null; - } - }, - renderEmbed: (embedTarget) => , - disableFileDrop: isStandalone, - }), - [dispatch, notify, settings.mediaDirectory, isStandalone], - ); - // Flush any unsaved edit when the component unmounts (tab switch / close). useEffect(() => { return () => { @@ -483,6 +433,32 @@ export function NoteEditor({ const showSource = effectiveMode === "edit" || effectiveMode === "split"; const showRendered = effectiveMode === "rendered" || effectiveMode === "split"; + // Build the pane-sync context value for the markdown editor panes. + const paneSyncValue: PaneSyncContextValue = { + source: { + cursorRequest: sourceCursor.request, + scrollRequest: descriptorSupportsScrollSync ? sourceScroll.request : undefined, + focusRequest: sourceFocus.request, + onCursorChange: handleSourceCursorChange, + onScrollChange: descriptorSupportsScrollSync ? handleSourceScrollChange : undefined, + onFocus: handleSourceFocus, + isReadOnly: descriptorSourceProtected, + }, + rendered: { + cursorRequest: renderedCursor.request, + scrollRequest: descriptorSupportsScrollSync ? renderedScroll.request : undefined, + focusRequest: renderedFocus.request, + onCursorChange: handleRenderedCursorChange, + onScrollChange: descriptorSupportsScrollSync ? handleRenderedScrollChange : undefined, + onFocus: handleRenderedFocus, + // Adapt state-setter to the context's plain callback signature. + onRegisterContextMenu: (builder) => + setNoteViewCtxBuilder(builder as NoteViewContextMenuBuilder | null), + }, + callbacks, + isStandalone, + }; + // Publish the active document to plugins (word count, etc.). useEffect(() => { if (saveState === "loading") { @@ -613,52 +589,36 @@ export function NoteEditor({ ) : ( -
- {effectiveMode === "edit" && } -
- {showSource && ( -
- +
+ {effectiveMode === "edit" && } +
+ {showSource && ( +
+ +
+ )} + {showRendered && noteRenderer && ( + {} : handleChange} + onChange={handleChange} callbacks={callbacks} - focusRequest={sourceFocus.request} - onFocus={handleSourceFocus} - scrollRequest={descriptorSupportsScrollSync ? sourceScroll.request : undefined} - onScrollChange={ - descriptorSupportsScrollSync ? handleSourceScrollChange : undefined + isStandalone={isStandalone} + onRegisterContextMenu={(builder) => + setNoteViewCtxBuilder(builder as NoteViewContextMenuBuilder | null) } - cursorRequest={sourceCursor.request} - onCursorChange={handleSourceCursorChange} /> -
- )} - {showRendered && noteRenderer && ( - - )} - {showRendered && !noteRenderer && ( -
- No renderer registered for “{frontType}”. -
- )} + )} + {showRendered && !noteRenderer && ( +
+ No renderer registered for “{frontType}”. +
+ )} +
-
+ )}
diff --git a/apps/web/src/state/use-editor-callbacks.tsx b/apps/web/src/state/use-editor-callbacks.tsx new file mode 100644 index 0000000..8659861 --- /dev/null +++ b/apps/web/src/state/use-editor-callbacks.tsx @@ -0,0 +1,69 @@ +import type { EditorCallbacks } from "@notes/editor"; +import { useMemo } from "react"; +import { api } from "../api/client"; +import { EmbedWidget } from "../components/embed-widget"; +import { + importedFilePath, + markdownForImportedFile, + normalizeMediaDirectory, + toBase64, +} from "../lib/images"; +import { useWorkspace } from "./app-context"; +import { useAppServices } from "./app-services"; +import { useToasts } from "./toast"; + +function basename(path: string): string { + return (path.split("/").pop() ?? path).replace(/\.[^.]+$/, ""); +} + +/** + * Builds the EditorCallbacks object for the active Tome context. + * Centralises wikilink navigation, tag/note listing, file imports, and embed + * rendering so any component that needs them can call this hook instead of + * inlining the same useMemo. + */ +export function useEditorCallbacks(isStandalone: boolean): EditorCallbacks { + const { dispatch } = useWorkspace(); + const { settings } = useAppServices(); + const { notify } = useToasts(); + + return useMemo( + () => ({ + onOpenWikilink: (name) => { + void (async () => { + const resolved = await api.resolve(name); + if (resolved.path) { + dispatch({ type: "openFile", path: resolved.path, title: name }); + return; + } + const newPath = `${name}.md`; + await api.create(newPath, `# ${name}\n\n`).catch(() => undefined); + dispatch({ type: "openFile", path: newPath, title: name }); + })(); + }, + onOpenFile: (path) => dispatch({ type: "openFile", path, title: basename(path) }), + listNotes: async () => (await api.notes()).notes, + listTags: async () => (await api.tags()).tags.map((tag) => tag.tag), + onImportFile: isStandalone + ? undefined + : async (file) => { + const mediaPath = importedFilePath( + file, + normalizeMediaDirectory(settings.mediaDirectory), + ); + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + await api.createBinary(mediaPath, toBase64(bytes)); + notify(`Imported file saved to ${mediaPath}`, { kind: "success" }); + return markdownForImportedFile(mediaPath, file.type, api.fileRawUrl(mediaPath)); + } catch { + notify("Couldn't import dropped file", { kind: "error" }); + return null; + } + }, + renderEmbed: (embedTarget) => , + disableFileDrop: isStandalone, + }), + [dispatch, notify, settings.mediaDirectory, isStandalone], + ); +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 5ff76d9..9b40097 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,7 +1,7 @@ export * from "./types"; -export * from "./markdown-editor"; export * from "./markdown-note-view"; export * from "./note-view-descriptor"; +export * from "./pane-sync-context"; export * from "./use-pane-sync"; export * from "./rendered-editor"; export * from "./toolbar"; diff --git a/packages/editor/src/markdown-editor.tsx b/packages/editor/src/markdown-editor.tsx deleted file mode 100644 index 089cded..0000000 --- a/packages/editor/src/markdown-editor.tsx +++ /dev/null @@ -1,271 +0,0 @@ -/** - * MarkdownEditor — a self-contained inline markdown component. - * - * This component is used as a lightweight inline editor wherever a markdown - * snippet needs to be rendered or edited in place (e.g. board cards, calendar - * event descriptions). It does NOT handle file I/O, registry routing, or the - * full note-editor lifecycle — those concerns live in NoteEditor in apps/web. - * - * Props summary: - * - value / onChange — the raw markdown string - * - mode — "edit" | "split" | "rendered" - * - callbacks — optional EditorCallbacks for wikilinks, embeds, etc. - * - viewState / onViewStateChange — cursor/scroll/focus persistence - */ -import type { NoteViewContextMenuBuilder } from "@notes/ui"; -import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from "react"; -import { RenderedEditor } from "./rendered-editor"; -import { NativeSourceEditor } from "./native-source-editor"; -import { EditorToolbar } from "./toolbar"; -import { - EDITOR_MODES, - type CursorRequest, - type EditorCallbacks, - type EditorMode, - type FocusRequest, - type ScrollRequest, -} from "./types"; -import { useCursorSync, useFocusSync, useScrollSync } from "./use-pane-sync"; - -export type MarkdownPane = "source" | "rendered"; - -export interface MarkdownViewState { - sourceCursor: number; - renderedCursor: number; - sourceScrollRatio: number; - renderedScrollRatio: number; - lastFocusedPane: MarkdownPane; - splitScrollSync: boolean; -} - -export const DEFAULT_MARKDOWN_VIEW_STATE: MarkdownViewState = { - sourceCursor: 0, - renderedCursor: 1, - sourceScrollRatio: 0, - renderedScrollRatio: 0, - lastFocusedPane: "rendered", - splitScrollSync: true, -}; - -interface MarkdownEditorProps { - mode: EditorMode; - path?: string; - value: string; - onChange: (markdown: string) => void; - disableToolbarInEdit?: boolean; - viewState?: MarkdownViewState; - onViewStateChange?: (patch: Partial) => void; - syncSplitScroll?: boolean; - isStandalone?: boolean; - isReadOnly?: boolean; - callbacks?: EditorCallbacks; - setNoteViewCtxBuilder?: Dispatch>; -} - -export interface RendererProps { - path: string; - value: string; - onChange: (markdown: string) => void; - callbacks?: EditorCallbacks; - isStandalone?: boolean; - cursorRequest?: CursorRequest; - scrollRequest?: ScrollRequest; - onCursorChange?: (position: number) => void; - onScrollChange?: (ratio: number) => void; - onFocus?: () => void; - focusRequest?: FocusRequest; - onRegisterContextMenu?: Dispatch>; -} - -/** - * Lightweight inline markdown editor. Use for embedding a markdown view - * inside other components (board cards, modals, etc.). - * - * For the full note-editor experience with file I/O, registry routing, and - * save state, use `NoteEditor` in apps/web instead. - */ -export function MarkdownEditor({ - mode, - value, - onChange, - disableToolbarInEdit = false, - viewState, - onViewStateChange, - syncSplitScroll = true, - isStandalone = false, - isReadOnly = false, - callbacks, -}: MarkdownEditorProps) { - const sourceCursor = useCursorSync( - viewState?.sourceCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceCursor, - ); - const renderedCursor = useCursorSync( - viewState?.renderedCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedCursor, - ); - const sourceScroll = useScrollSync( - viewState?.sourceScrollRatio ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceScrollRatio, - ); - const renderedScroll = useScrollSync( - viewState?.renderedScrollRatio ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedScrollRatio, - ); - const sourceFocus = useFocusSync(); - const renderedFocus = useFocusSync(); - - const activePaneRef = useRef( - viewState?.lastFocusedPane ?? DEFAULT_MARKDOWN_VIEW_STATE.lastFocusedPane, - ); - const sourceCursorPosRef = useRef( - viewState?.sourceCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceCursor, - ); - const renderedCursorPosRef = useRef( - viewState?.renderedCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedCursor, - ); - const prevModeRef = useRef(mode); - // Scroll-sync lock refs — prevent feedback loops when programmatically scrolling. - const sourceScrollLock = useRef(false); - const renderedScrollLock = useRef(false); - - const emitViewState = useCallback( - (patch: Partial) => onViewStateChange?.(patch), - [onViewStateChange], - ); - - const preferredPaneForMode = useCallback((nextMode: EditorMode): MarkdownPane => { - if (nextMode === "edit") return "source"; - if (nextMode === "rendered") return "rendered"; - return activePaneRef.current; - }, []); - - const requestPaneFocus = useCallback( - (pane: MarkdownPane) => { - if (pane === "source") sourceFocus.send(); - else renderedFocus.send(); - }, - [sourceFocus, renderedFocus], - ); - - useEffect(() => { - requestPaneFocus(preferredPaneForMode(mode)); - }, [mode, preferredPaneForMode, requestPaneFocus]); - - useEffect(() => { - const prev = prevModeRef.current; - if (prev === mode) return; - - if (mode === "edit") { - const position = - activePaneRef.current === "rendered" - ? renderedCursorPosRef.current - : sourceCursorPosRef.current; - sourceCursor.send(position); - requestPaneFocus("source"); - } else if (mode === "rendered") { - const position = - activePaneRef.current === "source" - ? sourceCursorPosRef.current - : renderedCursorPosRef.current; - renderedCursor.send(position); - requestPaneFocus("rendered"); - } else { - requestPaneFocus(preferredPaneForMode(mode)); - } - - prevModeRef.current = mode; - }, [mode, preferredPaneForMode, requestPaneFocus, sourceCursor, renderedCursor]); - - const handleSourceFocus = useCallback(() => { - activePaneRef.current = "source"; - emitViewState({ lastFocusedPane: "source" }); - }, [emitViewState]); - - const handleRenderedFocus = useCallback(() => { - activePaneRef.current = "rendered"; - emitViewState({ lastFocusedPane: "rendered" }); - }, [emitViewState]); - - const handleSourceCursorChange = useCallback( - (position: number) => { - sourceCursorPosRef.current = position; - emitViewState({ sourceCursor: position }); - }, - [emitViewState], - ); - - const handleRenderedCursorChange = useCallback( - (position: number) => { - renderedCursorPosRef.current = position; - emitViewState({ renderedCursor: position }); - }, - [emitViewState], - ); - - const handleSourceScrollChange = useCallback( - (ratio: number) => { - emitViewState({ sourceScrollRatio: ratio }); - if (!syncSplitScroll || mode !== "split") return; - if (sourceScrollLock.current) { - sourceScrollLock.current = false; - return; - } - renderedScrollLock.current = true; - renderedScroll.send(ratio); - }, - [emitViewState, mode, syncSplitScroll, renderedScroll], - ); - - const handleRenderedScrollChange = useCallback( - (ratio: number) => { - emitViewState({ renderedScrollRatio: ratio }); - if (!syncSplitScroll || mode !== "split") return; - if (renderedScrollLock.current) { - renderedScrollLock.current = false; - return; - } - sourceScrollLock.current = true; - sourceScroll.send(ratio); - }, - [emitViewState, mode, syncSplitScroll, sourceScroll], - ); - - const supportedModes = EDITOR_MODES; - const effectiveMode = supportedModes.includes(mode) ? mode : (supportedModes[0] ?? "rendered"); - const showSource = effectiveMode === "edit" || effectiveMode === "split"; - const showRendered = effectiveMode === "rendered" || effectiveMode === "split"; - - return ( -
- {effectiveMode === "edit" && disableToolbarInEdit && } -
- {showSource && ( -
- {} : onChange} - callbacks={callbacks} - focusRequest={sourceFocus.request} - onFocus={handleSourceFocus} - scrollRequest={sourceScroll.request} - onScrollChange={handleSourceScrollChange} - cursorRequest={sourceCursor.request} - onCursorChange={handleSourceCursorChange} - /> -
- )} - {showRendered && ( - - )} -
-
- ); -} diff --git a/packages/editor/src/native-source-editor.tsx b/packages/editor/src/native-source-editor.tsx index 861b37c..1a8a44e 100644 --- a/packages/editor/src/native-source-editor.tsx +++ b/packages/editor/src/native-source-editor.tsx @@ -8,37 +8,55 @@ useRef, } from "react"; import { - type CursorRequest, droppedPathInsertion, + NOTES_PATH_MIME, type EditorCallbacks, + type CursorRequest, type FocusRequest, - NOTES_PATH_MIME, type ScrollRequest, } from "./types"; +import { useSourcePaneSync } from "./pane-sync-context"; +/** + * When rendered inside a PaneSyncProvider (i.e. within NoteEditor), all sync + * props come from context. When used standalone, props are used as fallbacks. + * Only `value` and `onChange` are always required. + */ interface NativeSourceEditorProps { value: string; onChange: (markdown: string) => void; + // All below are fallbacks for standalone use outside a PaneSyncProvider. callbacks?: EditorCallbacks; - scrollRequest?: ScrollRequest; // The other view/mode set the request - onScrollChange?: (ratio: number) => void; // Send the other view/mode the request - focusRequest?: FocusRequest; // The other view/mode set the request - onFocus?: () => void; // Send the other view/mode the request - cursorRequest?: CursorRequest; // The other view/mode set the request - onCursorChange?: (position: number) => void; // Send the other view/mode the request + scrollRequest?: ScrollRequest; + onScrollChange?: (ratio: number) => void; + focusRequest?: FocusRequest; + onFocus?: () => void; + cursorRequest?: CursorRequest; + onCursorChange?: (position: number) => void; } export function NativeSourceEditor({ value, onChange, - callbacks, - scrollRequest, - onScrollChange, - focusRequest, - onFocus, - cursorRequest, - onCursorChange, + callbacks: callbacksProp, + scrollRequest: scrollRequestProp, + onScrollChange: onScrollChangeProp, + focusRequest: focusRequestProp, + onFocus: onFocusProp, + cursorRequest: cursorRequestProp, + onCursorChange: onCursorChangeProp, }: NativeSourceEditorProps) { + // Context wins over props; props are fallbacks for standalone usage. + const ctx = useSourcePaneSync(); + const callbacks = ctx?.callbacks ?? callbacksProp; + const scrollRequest = ctx?.scrollRequest ?? scrollRequestProp; + const onScrollChange = ctx?.onScrollChange ?? onScrollChangeProp; + const focusRequest = ctx?.focusRequest ?? focusRequestProp; + const onFocus = ctx?.onFocus ?? onFocusProp; + const cursorRequest = ctx?.cursorRequest ?? cursorRequestProp; + const onCursorChange = ctx?.onCursorChange ?? onCursorChangeProp; + const effectiveOnChange = ctx?.isReadOnly ? () => {} : onChange; + const viewRef = useRef(null); const suppressScrollRef = useRef(false); @@ -176,7 +194,7 @@ export function NativeSourceEditor({ className="source-editor" spellCheck="false" value={value} - onChange={(event) => onChange(event.target.value)} + onChange={(event) => effectiveOnChange(event.target.value)} onDragOver={handleDragover} onDrop={handleDrop} onPaste={handlePaste} diff --git a/packages/editor/src/note-view-descriptor.ts b/packages/editor/src/note-view-descriptor.ts index b974251..76c7af3 100644 --- a/packages/editor/src/note-view-descriptor.ts +++ b/packages/editor/src/note-view-descriptor.ts @@ -7,7 +7,7 @@ import type { NoteTypeProvider, NoteTypeToolbarItem } from "@notes/core"; import type { ComponentType, ReactNode } from "react"; import type { NoteViewContextMenuBuilder, ContextMenuEntry } from "@notes/ui"; -import type { RendererProps } from "./markdown-editor"; +import type { RendererProps } from "./types"; export type NoteViewComponent = ComponentType; diff --git a/packages/editor/src/pane-sync-context.tsx b/packages/editor/src/pane-sync-context.tsx new file mode 100644 index 0000000..f3f47d5 --- /dev/null +++ b/packages/editor/src/pane-sync-context.tsx @@ -0,0 +1,81 @@ +/** + * PaneSyncContext — shared state for the split-editor pane pair. + * + * NoteEditor provides this context; NativeSourceEditor and RenderedEditor + * consume it via the hooks below instead of receiving props. Renderers that + * do not need sync (canvas, board, table) can ignore it entirely. + * + * The context is nullable — hooks return undefined when called outside a + * PaneSyncProvider, which lets RenderedEditor work standalone (board cards, + * calendar modals) without sync or callbacks. + */ +import { createContext, type ReactNode, useContext } from "react"; +import type { CursorRequest, EditorCallbacks, FocusRequest, ScrollRequest } from "./types"; + +/** State exposed to the source (raw-text) pane. */ +export interface SourcePaneState { + cursorRequest: CursorRequest; + scrollRequest: ScrollRequest | undefined; + focusRequest: FocusRequest; + onCursorChange: (position: number) => void; + onScrollChange: ((ratio: number) => void) | undefined; + onFocus: () => void; + /** When true, the source pane should be read-only. */ + isReadOnly: boolean; +} + +/** State exposed to the rendered (WYSIWYG) pane. */ +export interface RenderedPaneState { + cursorRequest: CursorRequest; + scrollRequest: ScrollRequest | undefined; + focusRequest: FocusRequest; + onCursorChange: (position: number) => void; + onScrollChange: ((ratio: number) => void) | undefined; + onFocus: () => void; + /** + * Called by the rendered pane to register / unregister a note-type-specific + * context-menu builder. Pass null to unregister. + */ + onRegisterContextMenu: (builder: ((target: Element | null) => unknown[] | null) | null) => void; +} + +export interface PaneSyncContextValue { + source: SourcePaneState; + rendered: RenderedPaneState; + callbacks: EditorCallbacks; + isStandalone: boolean; +} + +const PaneSyncContext = createContext(null); + +export function PaneSyncProvider({ + value, + children, +}: { + value: PaneSyncContextValue; + children: ReactNode; +}) { + return {children}; +} + +/** + * Returns the source-pane state from the nearest PaneSyncProvider. + * Returns undefined when called outside a provider (standalone use). + */ +export function useSourcePaneSync(): + (SourcePaneState & Pick) | undefined { + const ctx = useContext(PaneSyncContext); + if (!ctx) return undefined; + return { ...ctx.source, callbacks: ctx.callbacks, isStandalone: ctx.isStandalone }; +} + +/** + * Returns the rendered-pane state from the nearest PaneSyncProvider. + * Returns undefined when called outside a provider (standalone use). + */ +export function useRenderedPaneSync(): + (RenderedPaneState & Pick) | undefined { + const ctx = useContext(PaneSyncContext); + if (!ctx) return undefined; + return { ...ctx.rendered, callbacks: ctx.callbacks, isStandalone: ctx.isStandalone }; +} diff --git a/packages/editor/src/rendered-editor.tsx b/packages/editor/src/rendered-editor.tsx index e029bf6..6c1048d 100644 --- a/packages/editor/src/rendered-editor.tsx +++ b/packages/editor/src/rendered-editor.tsx @@ -32,6 +32,7 @@ import type { ScrollRequest, WikiSuggestion, } from "./types"; +import { useRenderedPaneSync } from "./pane-sync-context"; import { WikilinkDecorator } from "./wikilink-decorator"; interface MarkdownStorage { @@ -89,6 +90,7 @@ interface SuggestState { interface RenderedEditorProps { value: string; onChange: (markdown: string) => void; + // Fallbacks for standalone use — context wins when both are present. callbacks?: EditorCallbacks; isStandalone?: boolean; toolbarDisabled?: boolean; @@ -104,16 +106,26 @@ interface RenderedEditorProps { export function RenderedEditor({ value, onChange, - callbacks, - isStandalone, + callbacks: callbacksProp, + isStandalone: isStandaloneProp, toolbarDisabled = false, - cursorRequest, - scrollRequest, - onCursorChange, - onScrollChange, - onFocus, - focusRequest, + cursorRequest: cursorRequestProp, + scrollRequest: scrollRequestProp, + onCursorChange: onCursorChangeProp, + onScrollChange: onScrollChangeProp, + onFocus: onFocusProp, + focusRequest: focusRequestProp, }: RenderedEditorProps) { + // Context wins over props; props are fallbacks for standalone usage. + const ctx = useRenderedPaneSync(); + const callbacks = ctx?.callbacks ?? callbacksProp; + const isStandalone = ctx?.isStandalone ?? isStandaloneProp ?? false; + const cursorRequest = ctx?.cursorRequest ?? cursorRequestProp; + const scrollRequest = ctx?.scrollRequest ?? scrollRequestProp; + const onCursorChange = ctx?.onCursorChange ?? onCursorChangeProp; + const onScrollChange = ctx?.onScrollChange ?? onScrollChangeProp; + const onFocus = ctx?.onFocus ?? onFocusProp; + const focusRequest = ctx?.focusRequest ?? focusRequestProp; const { settings } = useAppServices(); const [findOpen, setFindOpen] = useState(false); const currentParts = parseFrontmatter(value); diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index 419bd7b..36e7264 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -6,6 +6,28 @@ export type EditorMode = NoteViewMode; export const EDITOR_MODES: EditorMode[] = ["edit", "split", "rendered"]; +// ── Markdown view state ─────────────────────────────────────────────────────── + +export type MarkdownPane = "source" | "rendered"; + +export interface MarkdownViewState { + sourceCursor: number; + renderedCursor: number; + sourceScrollRatio: number; + renderedScrollRatio: number; + lastFocusedPane: MarkdownPane; + splitScrollSync: boolean; +} + +export const DEFAULT_MARKDOWN_VIEW_STATE: MarkdownViewState = { + sourceCursor: 0, + renderedCursor: 1, + sourceScrollRatio: 0, + renderedScrollRatio: 0, + lastFocusedPane: "rendered", + splitScrollSync: true, +}; + export interface WikiSuggestion { title: string; path: string; @@ -86,3 +108,25 @@ export interface EditorCallbacks { */ disableFileDrop?: boolean; } + +// ── Renderer contract ───────────────────────────────────────────────────────── + +/** + * Props for registry-registered note renderers (canvas, board, table, markdown, …). + * + * Cursor/scroll/focus sync is no longer in RendererProps — renderers that need it + * consume PaneSyncContext directly via the hooks in pane-sync-context.tsx. + */ +export interface RendererProps { + path: string; + value: string; + onChange: (markdown: string) => void; + callbacks?: EditorCallbacks; + isStandalone?: boolean; + /** + * Called by the renderer to register a note-specific context-menu builder. + * Pass null (or call with null at cleanup) to unregister. + * Typed as unknown[] to avoid importing @notes/ui here; consumers cast to ContextMenuEntry[]. + */ + onRegisterContextMenu?: (builder: ((target: Element | null) => unknown[] | null) | null) => void; +} diff --git a/packages/note-boards/src/board-card-modal.tsx b/packages/note-boards/src/board-card-modal.tsx index 2d140c0..402c926 100644 --- a/packages/note-boards/src/board-card-modal.tsx +++ b/packages/note-boards/src/board-card-modal.tsx @@ -1,4 +1,4 @@ -import { MarkdownEditor } from "@notes/editor"; +import { RenderedEditor } from "@notes/editor"; import { Modal, ModalBody, ModalHeader } from "@notes/ui"; import type { RichCard } from "./board-format"; @@ -71,11 +71,7 @@ export function BoardCardModal({ - updateCardState({ ...card, body })} - /> + updateCardState({ ...card, body })} /> ); diff --git a/packages/note-boards/src/board-card.tsx b/packages/note-boards/src/board-card.tsx index fb48c97..5a3291a 100644 --- a/packages/note-boards/src/board-card.tsx +++ b/packages/note-boards/src/board-card.tsx @@ -1,4 +1,4 @@ -import { MarkdownEditor } from "@notes/editor"; +import { RenderedEditor } from "@notes/editor"; import { PopupMenu, useDraggable, usePreventChildDrag } from "@notes/ui"; import { DragEvent, useEffect, useRef, useState } from "react"; import type { IBoardColumn, RichCard } from "./board-format"; @@ -282,9 +282,8 @@ export function BoardCard({
- updateCardState({ ...card, body })} />
diff --git a/packages/note-calendar/src/event-details.tsx b/packages/note-calendar/src/event-details.tsx index c43714c..48a4376 100644 --- a/packages/note-calendar/src/event-details.tsx +++ b/packages/note-calendar/src/event-details.tsx @@ -1,4 +1,4 @@ -import { MarkdownEditor } from "@notes/editor"; +import { RenderedEditor } from "@notes/editor"; import type { RichEvent } from "./calendar-format"; const DURATION_OPTIONS = [ @@ -106,9 +106,8 @@ export function EventDetails({
- onUpdateEvent({ ...selectedEvent, body })} />
From 294c8a6302484e67ba5f784a799f6294ebd90a5c Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Mon, 10 Aug 2026 22:23:09 -0500 Subject: [PATCH 4/5] refactor: cohesive NoteType naming; uniform RendererProps; typed viewComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Naming — one language, everything is a 'note type' NoteTypeProvider renamed to NoteTypeDescriptor: - The 'Provider' suffix implied a React context provider; NoteEditor already called it 'activeDescriptor', which is the right term. NoteViewRegistry merged into NoteTypeRegistry: - Both classes stored the same NoteTypeDescriptor objects; two separate registries was confusing mixing of 'Type' and 'View' namespaces. - NoteTypeRegistry.register() now returns a () => void disposer so plugins and built-ins can unregister cleanly (NoteViewDisposer removed). Registration functions renamed for consistency: - registerBuiltinNoteView -> registerBuiltinNoteType (all note-* packages) - registerMarkdownNoteView -> registerMarkdownNoteType (@notes/editor) - PluginContext.registerNoteView -> registerNoteType - Variable noteViewRegistry -> noteTypeRegistry everywhere ## NoteTypeViewDescriptor — properly typed viewComponent Added NoteTypeViewDescriptor in packages/editor/src/note-view-descriptor.ts, extending NoteTypeDescriptor with React-typed fields: - viewComponent?: ComponentType (not unknown) - toolbarItems?: TypedNoteTypeToolbarItem[] - contextMenuBuilder?: NoteViewContextMenuBuilder All registerBuiltinNoteType functions now annotate their descriptor as NoteTypeViewDescriptor so TypeScript validates the view component type at registration time, not just at the point of use. ## Uniform RendererProps for all note view components BoardView, CanvasView, CalendarView, MermaidView, GridView all previously had bespoke local props interfaces. Each now uses RendererProps directly. All 156 tests pass; typecheck clean; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/src/app.tsx | 44 ++++++++--------- apps/web/src/components/embed-widget.tsx | 4 +- apps/web/src/components/note-editor.tsx | 4 +- apps/web/src/state/app-services.tsx | 8 ++-- apps/web/src/state/use-plugins.ts | 10 ++-- packages/core/src/contracts.ts | 4 +- packages/core/src/index.ts | 1 - packages/core/src/markdown-note-type.ts | 6 +-- packages/core/src/note-type-registry.test.ts | 4 +- packages/core/src/note-type-registry.ts | 44 ++++++++++------- packages/core/src/note-view-registry.ts | 36 +------------- packages/editor/src/markdown-note-view.ts | 20 +++++--- packages/editor/src/note-view-descriptor.ts | 48 ++++++++++++++----- packages/note-boards/src/board-note-type.ts | 9 ++-- packages/note-boards/src/board-view.tsx | 26 ++-------- .../note-calendar/src/calendar-note-type.ts | 9 ++-- packages/note-calendar/src/calendar-view.tsx | 9 +--- packages/note-canvas/src/canvas-note-type.ts | 9 ++-- packages/note-canvas/src/canvas-view.tsx | 11 +---- packages/note-grid/src/grid-note-type.ts | 9 ++-- packages/note-grid/src/grid-view.tsx | 9 +--- .../note-mermaid/src/mermaid-note-type.ts | 9 ++-- packages/note-mermaid/src/mermaid-view.tsx | 8 +--- packages/note-tables/src/table-note-type.ts | 9 ++-- packages/plugin-host/src/context.ts | 6 +-- .../plugin-host/src/plugin-manager.test.ts | 2 +- packages/plugin-host/src/plugin-manager.ts | 4 +- 27 files changed, 167 insertions(+), 195 deletions(-) diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 2bcece6..eb6de1a 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,20 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { NoteViewRegistry, formatCombo } from "@notes/core"; -import { registerMarkdownNoteView } from "@notes/editor"; -import { emptyCanvas, registerBuiltinNoteView as registerCanvasNoteView } from "@notes/note-canvas"; -import { emptyBoard, registerBuiltinNoteView as registerBoardNoteView } from "@notes/note-boards"; +import { NoteTypeRegistry, formatCombo } from "@notes/core"; +import { registerMarkdownNoteType } from "@notes/editor"; +import { emptyCanvas, registerBuiltinNoteType as registerCanvasNoteType } from "@notes/note-canvas"; +import { emptyBoard, registerBuiltinNoteType as registerBoardNoteType } from "@notes/note-boards"; import { emptyCalendar, - registerBuiltinNoteView as registerCalendarNoteView, + registerBuiltinNoteType as registerCalendarNoteType, } from "@notes/note-calendar"; -import { emptyGrid, registerBuiltinNoteView as registerGridNoteView } from "@notes/note-grid"; +import { emptyGrid, registerBuiltinNoteType as registerGridNoteType } from "@notes/note-grid"; import { emptyMermaid, - registerBuiltinNoteView as registerMermaidNoteView, + registerBuiltinNoteType as registerMermaidNoteType, } from "@notes/note-mermaid"; import { emptyTableMarkdown, - registerBuiltinNoteView as registerTableNoteView, + registerBuiltinNoteType as registerTableNoteType, } from "@notes/note-tables"; import type { PluginManifest } from "@notes/plugin-host"; import type { ThemeMeta } from "@notes/shared"; @@ -129,22 +129,22 @@ export function App() { const [externalThemes, setExternalThemes] = useState([]); const [pendingRestartPlugins, setPendingRestartPlugins] = useState([]); - // Create the NoteViewRegistry once and register all built-in note types. + // Create the NoteTypeRegistry once and register all built-in note types. // Kept here (not in usePlugins) so the registry is an explicit app-level - // concern; plugins extend it via PluginContext.registerNoteView. - const noteViewRegistry = useMemo(() => { - const registry = new NoteViewRegistry(); - registerMarkdownNoteView(registry); - registerCanvasNoteView(registry); - registerBoardNoteView(registry); - registerTableNoteView(registry); - registerMermaidNoteView(registry); - registerCalendarNoteView(registry); - registerGridNoteView(registry); + // concern; plugins extend it via PluginContext.registerNoteType. + const noteTypeRegistry = useMemo(() => { + const registry = new NoteTypeRegistry(); + registerMarkdownNoteType(registry); + registerCanvasNoteType(registry); + registerBoardNoteType(registry); + registerTableNoteType(registry); + registerMermaidNoteType(registry); + registerCalendarNoteType(registry); + registerGridNoteType(registry); return registry; }, []); - const plugins = usePlugins(noteViewRegistry); + const plugins = usePlugins(noteTypeRegistry); // Paths of freshly-created notes not yet modified/named (discarded on close). const [provisional, setProvisional] = useState>(new Set()); @@ -863,7 +863,7 @@ export function App() { setActiveDocument: (doc: { path: string; content: string; type: string } | null) => plugins.documentSignal.set(doc), fileHandlers: plugins.fileHandlers, - noteViewRegistry, + noteTypeRegistry, settings: settingsProps, undoableFileOps, }), @@ -882,7 +882,7 @@ export function App() { noteTypes, plugins.documentSignal, plugins.fileHandlers, - noteViewRegistry, + noteTypeRegistry, settingsProps, undoableFileOps, ], diff --git a/apps/web/src/components/embed-widget.tsx b/apps/web/src/components/embed-widget.tsx index fc89eab..0f649e7 100644 --- a/apps/web/src/components/embed-widget.tsx +++ b/apps/web/src/components/embed-widget.tsx @@ -173,9 +173,9 @@ export function EmbedWidget({ target }: { target: string }) { case "image": return {title}; case "mermaid": - return ; + return ; case "grid": - return ; + return ; case "table": return ; case "board": diff --git a/apps/web/src/components/note-editor.tsx b/apps/web/src/components/note-editor.tsx index ed545aa..426bb97 100644 --- a/apps/web/src/components/note-editor.tsx +++ b/apps/web/src/components/note-editor.tsx @@ -125,7 +125,7 @@ export function NoteEditor({ /** When true, disables file drop/paste and frontmatter type detection. */ isStandalone?: boolean; }) { - const { markModified, setActiveDocument, fileHandlers, noteViewRegistry } = useAppServices(); + const { markModified, setActiveDocument, fileHandlers, noteTypeRegistry } = useAppServices(); const { notify } = useToasts(); const callbacks = useEditorCallbacks(isStandalone); const stateKey = editorStateKey(path); @@ -417,7 +417,7 @@ export function NoteEditor({ const frontType = isCanvas ? CANVAS_NOTE_TYPE_ID : (frontmatterType(content) ?? MARKDOWN_NOTE_TYPE_ID); - const activeDescriptor = !pluginHandler && !isImage ? noteViewRegistry.get(frontType) : undefined; + const activeDescriptor = !pluginHandler && !isImage ? noteTypeRegistry.get(frontType) : undefined; const descriptorSourceProtected = activeDescriptor?.sourceProtected ?? false; const descriptorSupportedModes = activeDescriptor?.supportedModes ?? EDITOR_MODES; const descriptorSupportsScrollSync = activeDescriptor?.supportsScrollSync ?? false; diff --git a/apps/web/src/state/app-services.tsx b/apps/web/src/state/app-services.tsx index 9719c51..9853dc0 100644 --- a/apps/web/src/state/app-services.tsx +++ b/apps/web/src/state/app-services.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; import type { FileTypeHandler } from "@notes/plugin-host"; -import { NoteViewRegistry } from "@notes/core"; +import { NoteTypeRegistry } from "@notes/core"; import type { SettingsBodyProps } from "../components/settings-view"; import type { UndoableFileOps } from "../api/undoable-file-ops"; @@ -26,8 +26,8 @@ export interface AppServices { setActiveDocument: (doc: { path: string; content: string; type: string } | null) => void; /** Plugin-registered file-type handlers, keyed by file extension. */ fileHandlers: FileTypeHandler[]; - /** Registry of note-type view descriptors (built-ins + plugin-registered). */ - noteViewRegistry: NoteViewRegistry; + /** Registry of note-type descriptors (built-ins + plugin-registered). */ + noteTypeRegistry: NoteTypeRegistry; /** Everything the settings surface needs, so a settings tab can render. */ settings: SettingsBodyProps; /** File-level undoable operations (create, delete, rename). */ @@ -91,7 +91,7 @@ const AppServicesContext = createContext({ noteTypes: {}, setActiveDocument: noop, fileHandlers: [], - noteViewRegistry: new NoteViewRegistry(), + noteTypeRegistry: new NoteTypeRegistry(), settings: defaultSettings, undoableFileOps: { createFile: async () => {}, diff --git a/apps/web/src/state/use-plugins.ts b/apps/web/src/state/use-plugins.ts index c69c184..242f4e2 100644 --- a/apps/web/src/state/use-plugins.ts +++ b/apps/web/src/state/use-plugins.ts @@ -9,7 +9,7 @@ import { type PluginInfo, type StatusBarItem, } from "@notes/plugin-host"; -import { type NoteViewRegistry } from "@notes/core"; +import { type NoteTypeRegistry } from "@notes/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { localPlugins } from "../plugins"; @@ -48,7 +48,7 @@ async function loadTomePlugin(id: string): Promise { } } -export function usePlugins(noteViewRegistry: NoteViewRegistry): PluginsApi { +export function usePlugins(noteTypeRegistry: NoteTypeRegistry): PluginsApi { const documentSignal = useState(() => new Signal(null))[0]; const [pluginCommands, setPluginCommands] = useState([]); const [statusItems, setStatusItems] = useState([]); @@ -82,8 +82,8 @@ export function usePlugins(noteViewRegistry: NoteViewRegistry): PluginsApi { }); return () => setFileHandlers((prev) => prev.filter((h) => h !== handler)); }, - registerNoteView: (descriptor) => { - return noteViewRegistry.register(descriptor); + registerNoteType: (descriptor) => { + return noteTypeRegistry.register(descriptor); }, document: documentSignal, storage: window.localStorage, @@ -143,7 +143,7 @@ export function usePlugins(noteViewRegistry: NoteViewRegistry): PluginsApi { setStatusItems([]); setFileHandlers([]); }; - }, [documentSignal, noteViewRegistry]); + }, [documentSignal, noteTypeRegistry]); const toggle = useCallback((id: string, enabled: boolean) => { const manager = managerRef.current; diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 8eec73c..e5faecd 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -64,7 +64,7 @@ export interface NoteTypeToolbarItem { } /** Strategy/Factory provider for a note type (markdown, table, canvas, board, …). */ -export interface NoteTypeProvider { +export interface NoteTypeDescriptor { readonly id: string; detect(file: NoteFileDescriptor): boolean; @@ -86,7 +86,7 @@ export interface NoteTypeProvider { supportsScrollSync?: boolean; /** * Opaque view-component token. The editor package resolves this to a - * React ComponentType via the NoteViewRegistry. + * React ComponentType via the NoteTypeRegistry. */ viewComponent?: unknown; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 13cb9a3..249947a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,6 @@ export * from "./event-bus"; export * from "./registry"; export * from "./command-bus"; export * from "./note-type-registry"; -export * from "./note-view-registry"; export * from "./markdown-note-type"; export * from "./hotkeys"; export * from "./undo-stack"; diff --git a/packages/core/src/markdown-note-type.ts b/packages/core/src/markdown-note-type.ts index 1787d5f..02bebd8 100644 --- a/packages/core/src/markdown-note-type.ts +++ b/packages/core/src/markdown-note-type.ts @@ -1,9 +1,9 @@ -import type { NoteFileDescriptor, NoteTypeProvider } from "./contracts"; +import type { NoteFileDescriptor, NoteTypeDescriptor } from "./contracts"; export const MARKDOWN_NOTE_TYPE_ID = "markdown"; /** Default note type — any `.md` file that isn't claimed by a more specific type. */ -export const markdownNoteType: NoteTypeProvider = { +export const markdownNoteType: NoteTypeDescriptor = { id: MARKDOWN_NOTE_TYPE_ID, detect(file: NoteFileDescriptor): boolean { return file.path.toLowerCase().endsWith(".md"); @@ -11,6 +11,6 @@ export const markdownNoteType: NoteTypeProvider = { supportedModes: ["edit", "split", "rendered"], sourceProtected: false, supportsScrollSync: true, - // viewComponent is set by packages/editor via registerBuiltinNoteView + // viewComponent is set by packages/editor via registerBuiltinNoteType // to avoid a circular dependency (core must stay React-free). }; diff --git a/packages/core/src/note-type-registry.test.ts b/packages/core/src/note-type-registry.test.ts index 3c0009c..1569cb3 100644 --- a/packages/core/src/note-type-registry.test.ts +++ b/packages/core/src/note-type-registry.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { NoteTypeProvider } from "./contracts"; +import type { NoteTypeDescriptor } from "./contracts"; import { markdownNoteType, MARKDOWN_NOTE_TYPE_ID } from "./markdown-note-type"; import { NoteTypeRegistry } from "./note-type-registry"; -const tableType: NoteTypeProvider = { +const tableType: NoteTypeDescriptor = { id: "table", detect: (file) => file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "table", }; diff --git a/packages/core/src/note-type-registry.ts b/packages/core/src/note-type-registry.ts index af43b46..4047962 100644 --- a/packages/core/src/note-type-registry.ts +++ b/packages/core/src/note-type-registry.ts @@ -1,38 +1,46 @@ -import type { NoteFileDescriptor, NoteTypeProvider } from "./contracts"; +import type { NoteFileDescriptor, NoteTypeDescriptor } from "./contracts"; import { Registry } from "./registry"; /** - * Factory/registry that resolves a file to its note-type provider. Providers - * are checked in registration order; a single provider may be marked as the - * fallback (matched last), which the default markdown provider uses. + * Unified registry for note types — stores the full NoteTypeDescriptor + * for every registered note type. + * + * - Detection (`detect`) is used server-side to match a file to its type. + * - View capabilities (`viewComponent`, `supportedModes`, …) are used + * client-side; the optional fields are simply absent in server-only usage. + * + * A single provider may be marked as the fallback (matched last). */ export class NoteTypeRegistry { - private readonly providers = new Registry(); + private readonly providers = new Registry(); private fallbackId?: string; - register(provider: NoteTypeProvider, options?: { fallback?: boolean }): void { - this.providers.register(provider.id, provider); + /** + * Registers a note-type descriptor. Returns a disposer that unregisters it; + * callers that do not need to unregister may ignore the return value. + */ + register(descriptor: NoteTypeDescriptor, options?: { fallback?: boolean }): () => void { + this.providers.register(descriptor.id, descriptor); if (options?.fallback) { - this.fallbackId = provider.id; + this.fallbackId = descriptor.id; } + return () => { + if (this.fallbackId === descriptor.id) { + this.fallbackId = undefined; + } + this.providers.unregister(descriptor.id); + }; } - unregister(id: string): boolean { - if (this.fallbackId === id) { - this.fallbackId = undefined; - } - return this.providers.unregister(id); - } - - get(id: string): NoteTypeProvider | undefined { + get(id: string): NoteTypeDescriptor | undefined { return this.providers.get(id); } - list(): NoteTypeProvider[] { + list(): NoteTypeDescriptor[] { return this.providers.list(); } - detect(file: NoteFileDescriptor): NoteTypeProvider | undefined { + detect(file: NoteFileDescriptor): NoteTypeDescriptor | undefined { for (const provider of this.providers.list()) { if (provider.id === this.fallbackId) { continue; diff --git a/packages/core/src/note-view-registry.ts b/packages/core/src/note-view-registry.ts index 0760329..70675e7 100644 --- a/packages/core/src/note-view-registry.ts +++ b/packages/core/src/note-view-registry.ts @@ -1,34 +1,2 @@ -import type { NoteTypeProvider } from "./contracts"; -import { Registry } from "./registry"; - -export type NoteViewDisposer = () => void; - -/** - * Runtime registry that maps note-type IDs to their full NoteTypeProvider - * (including optional UI capability fields). Built-in note types call - * `register` at startup; plugins call `PluginContext.registerNoteView`. - * - * This is separate from NoteTypeRegistry (which is server/detection-focused) - * so the client-side view layer can be populated independently. - */ -export class NoteViewRegistry { - private readonly providers = new Registry(); - - /** - * Registers a note-type provider and returns a disposer that unregisters it. - */ - register(provider: NoteTypeProvider): NoteViewDisposer { - this.providers.register(provider.id, provider); - return () => { - this.providers.unregister(provider.id); - }; - } - - get(id: string): NoteTypeProvider | undefined { - return this.providers.get(id); - } - - list(): NoteTypeProvider[] { - return this.providers.list(); - } -} +// Merged into NoteTypeRegistry. This file is kept only as a transitional shim. +export { NoteTypeRegistry } from "./note-type-registry"; diff --git a/packages/editor/src/markdown-note-view.ts b/packages/editor/src/markdown-note-view.ts index 886eae3..9efee9c 100644 --- a/packages/editor/src/markdown-note-view.ts +++ b/packages/editor/src/markdown-note-view.ts @@ -1,16 +1,22 @@ /** - * Registers the built-in markdown note type with the NoteViewRegistry. + * Registers the built-in markdown note type with the NoteTypeRegistry. * * This lives in packages/editor (not packages/core) because the markdown - * NoteTypeProvider needs to reference RenderedEditor — a React component — + * NoteTypeDescriptor needs to reference RenderedEditor — a React component — * and packages/core must remain React-free. */ -import { markdownNoteType, type NoteViewRegistry, type NoteViewDisposer } from "@notes/core"; +import { markdownNoteType, type NoteTypeRegistry } from "@notes/core"; import { RenderedEditor } from "./rendered-editor"; +import type { NoteTypeViewDescriptor } from "./note-view-descriptor"; -export function registerMarkdownNoteView(registry: NoteViewRegistry): NoteViewDisposer { - return registry.register({ - ...markdownNoteType, +export function registerMarkdownNoteType(registry: NoteTypeRegistry): () => void { + const descriptor: NoteTypeViewDescriptor = { + id: markdownNoteType.id, + detect: markdownNoteType.detect.bind(markdownNoteType), + supportedModes: markdownNoteType.supportedModes, + sourceProtected: markdownNoteType.sourceProtected, + supportsScrollSync: markdownNoteType.supportsScrollSync, viewComponent: RenderedEditor, - }); + }; + return registry.register(descriptor); } diff --git a/packages/editor/src/note-view-descriptor.ts b/packages/editor/src/note-view-descriptor.ts index 76c7af3..b4e8019 100644 --- a/packages/editor/src/note-view-descriptor.ts +++ b/packages/editor/src/note-view-descriptor.ts @@ -1,10 +1,13 @@ /** - * React-typed narrowing helpers for the opaque UI capability fields on - * NoteTypeProvider. Because packages/core is React-free, these fields are - * typed as `unknown` there; this module provides safe cast helpers for the - * editor/UI layer where React is available. + * React-typed narrowing helpers and typed descriptor interface for note types. + * + * packages/core is React-free, so NoteTypeDescriptor uses `unknown` for its + * React-specific fields. This module provides: + * - NoteTypeViewDescriptor — a typed variant of NoteTypeDescriptor with + * properly typed viewComponent, toolbarItems, and contextMenuBuilder. + * - Accessor helpers (getNoteViewComponent etc.) for the opaque core fields. */ -import type { NoteTypeProvider, NoteTypeToolbarItem } from "@notes/core"; +import type { NoteTypeDescriptor, NoteTypeToolbarItem } from "@notes/core"; import type { ComponentType, ReactNode } from "react"; import type { NoteViewContextMenuBuilder, ContextMenuEntry } from "@notes/ui"; import type { RendererProps } from "./types"; @@ -18,19 +21,40 @@ export interface TypedNoteTypeToolbarItem extends Omit { + viewComponent?: NoteViewComponent; + toolbarItems?: TypedNoteTypeToolbarItem[]; + contextMenuBuilder?: NoteViewContextMenuBuilder; +} + +/** Extract the viewComponent from a descriptor (casts the opaque unknown field). */ +export function getNoteViewComponent( + descriptor: NoteTypeDescriptor, +): NoteViewComponent | undefined { + return descriptor.viewComponent as NoteViewComponent | undefined; } /** Extract and cast toolbarItems to typed ReactNode elements. */ -export function getNoteViewToolbarItems(provider: NoteTypeProvider): TypedNoteTypeToolbarItem[] { - return (provider.toolbarItems ?? []) as TypedNoteTypeToolbarItem[]; +export function getNoteViewToolbarItems( + descriptor: NoteTypeDescriptor, +): TypedNoteTypeToolbarItem[] { + return (descriptor.toolbarItems ?? []) as TypedNoteTypeToolbarItem[]; } /** Extract and cast the opaque contextMenuBuilder to a typed function. */ export function getNoteContextMenuBuilder( - provider: NoteTypeProvider, + descriptor: NoteTypeDescriptor, ): NoteViewContextMenuBuilder | undefined { - return provider.contextMenuBuilder as NoteViewContextMenuBuilder | undefined; + return descriptor.contextMenuBuilder as NoteViewContextMenuBuilder | undefined; } diff --git a/packages/note-boards/src/board-note-type.ts b/packages/note-boards/src/board-note-type.ts index 7ba4976..aee3375 100644 --- a/packages/note-boards/src/board-note-type.ts +++ b/packages/note-boards/src/board-note-type.ts @@ -1,10 +1,11 @@ -import type { NoteTypeProvider, NoteViewRegistry, NoteViewDisposer } from "@notes/core"; +import type { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeViewDescriptor } from "@notes/editor"; import { BoardView } from "./board-view"; export const BOARD_NOTE_TYPE_ID = "board"; /** Board notes are `.md` files with `type: board` frontmatter. */ -export const boardNoteType: NoteTypeProvider = { +export const boardNoteType: NoteTypeViewDescriptor = { id: BOARD_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "board"; @@ -15,7 +16,7 @@ export const boardNoteType: NoteTypeProvider = { viewComponent: BoardView, }; -/** Registers the board note type with the NoteViewRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteView(registry: NoteViewRegistry): NoteViewDisposer { +/** Registers the board note type with the NoteTypeRegistry — mirrors the plugin pattern. */ +export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { return registry.register(boardNoteType); } diff --git a/packages/note-boards/src/board-view.tsx b/packages/note-boards/src/board-view.tsx index 67a90f5..99c45d2 100644 --- a/packages/note-boards/src/board-view.tsx +++ b/packages/note-boards/src/board-view.tsx @@ -1,15 +1,5 @@ -import { - Fragment, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type DragEvent, - Dispatch, - SetStateAction, -} from "react"; -import { usePromptDialog } from "@notes/editor"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; +import { usePromptDialog, type RendererProps } from "@notes/editor"; import { debounce } from "@notes/core"; import { type NoteViewContextMenuBuilder } from "@notes/ui"; import { useUndoStack } from "@notes/web/src/state/undo-context"; @@ -23,14 +13,6 @@ import { serializeBoard, } from "./board-format"; -interface BoardViewProps { - value: string; - onChange: (markdown: string) => void; - path: string; - /** Called once on mount so the parent NoteEditor can show card-specific context menus. */ - onRegisterContextMenu?: Dispatch>; -} - interface CardDrag { cardId: string; fromColumn: string; @@ -42,7 +24,7 @@ interface DropTarget { beforeCardId: string | null; } -export function BoardView({ value, onChange, path, onRegisterContextMenu }: BoardViewProps) { +export function BoardView({ value, onChange, path, onRegisterContextMenu }: RendererProps) { const { openPrompt, promptDialog } = usePromptDialog(); const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseBoard(value)); @@ -346,7 +328,7 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar // a card shows card-specific actions instead of the generic edit menu. useEffect(() => { if (!onRegisterContextMenu) return; - const builder: import("@notes/ui").NoteViewContextMenuBuilder = (target) => { + const builder: NoteViewContextMenuBuilder = (target) => { const cardEl = target?.closest("[data-card-id]"); // Return [] (not null) so the generic edit menu (undo/redo/cut…) is // suppressed entirely — those commands don't apply to board operations. diff --git a/packages/note-calendar/src/calendar-note-type.ts b/packages/note-calendar/src/calendar-note-type.ts index 7038f91..3ea55e4 100644 --- a/packages/note-calendar/src/calendar-note-type.ts +++ b/packages/note-calendar/src/calendar-note-type.ts @@ -1,10 +1,11 @@ -import type { NoteTypeProvider, NoteViewRegistry, NoteViewDisposer } from "@notes/core"; +import type { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeViewDescriptor } from "@notes/editor"; import { CalendarView } from "./calendar-view"; export const CALENDAR_NOTE_TYPE_ID = "calendar"; /** Calendar notes are `.md` files with `type: calendar` frontmatter. */ -export const calendarNoteType: NoteTypeProvider = { +export const calendarNoteType: NoteTypeViewDescriptor = { id: CALENDAR_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "calendar"; @@ -15,7 +16,7 @@ export const calendarNoteType: NoteTypeProvider = { viewComponent: CalendarView, }; -/** Registers the calendar note type with the NoteViewRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteView(registry: NoteViewRegistry): NoteViewDisposer { +/** Registers the calendar note type with the NoteTypeRegistry — mirrors the plugin pattern. */ +export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { return registry.register(calendarNoteType); } diff --git a/packages/note-calendar/src/calendar-view.tsx b/packages/note-calendar/src/calendar-view.tsx index e3e94b0..ce4a0c5 100644 --- a/packages/note-calendar/src/calendar-view.tsx +++ b/packages/note-calendar/src/calendar-view.tsx @@ -1,4 +1,4 @@ -import { NoteToolbar } from "@notes/editor"; +import { NoteToolbar, type RendererProps } from "@notes/editor"; import { useUndoStack } from "@notes/web/src/state/undo-context"; import { debounce } from "@notes/core"; import { useCallback, useMemo, useState } from "react"; @@ -12,14 +12,9 @@ import { useGetEvents } from "./use-get-events"; import { useUpdateEvent } from "./use-update-event"; import { MONTHS, toIso } from "./utils"; -interface CalendarViewProps { - value: string; - onChange?: (markdown: string) => void; - path: string; -} type CalendarMode = "month" | "agenda"; -export function CalendarView({ value, path }: CalendarViewProps) { +export function CalendarView({ value, path }: RendererProps) { const undoStack = useUndoStack(); const [selectedEvent, setSelectedEvent] = useState(null); const [mode, setMode] = useState("month"); diff --git a/packages/note-canvas/src/canvas-note-type.ts b/packages/note-canvas/src/canvas-note-type.ts index 60ef7ba..51d6c0e 100644 --- a/packages/note-canvas/src/canvas-note-type.ts +++ b/packages/note-canvas/src/canvas-note-type.ts @@ -1,10 +1,11 @@ -import type { NoteTypeProvider, NoteViewRegistry, NoteViewDisposer } from "@notes/core"; +import type { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeViewDescriptor } from "@notes/editor"; import { CanvasView } from "./canvas-view"; export const CANVAS_NOTE_TYPE_ID = "canvas"; /** Canvas notes are `.canvas` files (JSONCanvas). */ -export const canvasNoteType: NoteTypeProvider = { +export const canvasNoteType: NoteTypeViewDescriptor = { id: CANVAS_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".canvas"); @@ -15,7 +16,7 @@ export const canvasNoteType: NoteTypeProvider = { viewComponent: CanvasView, }; -/** Registers the canvas note type with the NoteViewRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteView(registry: NoteViewRegistry): NoteViewDisposer { +/** Registers the canvas note type with the NoteTypeRegistry — mirrors the plugin pattern. */ +export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { return registry.register(canvasNoteType); } diff --git a/packages/note-canvas/src/canvas-view.tsx b/packages/note-canvas/src/canvas-view.tsx index 4c392bf..c2212c5 100644 --- a/packages/note-canvas/src/canvas-view.tsx +++ b/packages/note-canvas/src/canvas-view.tsx @@ -11,7 +11,7 @@ import { type PointerEvent as ReactPointerEvent, } from "react"; import { NoteEditor } from "@notes/web/src/components/note-editor"; -import { type EditorCallbacks, NoteToolbar, usePromptDialog } from "@notes/editor"; +import { NoteToolbar, usePromptDialog, type RendererProps } from "@notes/editor"; import { parseCanvas, serializeCanvas, @@ -20,13 +20,6 @@ import { type FileNode, } from "./canvas-format"; -interface CanvasViewProps { - value: string; - onChange: (text: string) => void; - callbacks?: EditorCallbacks; - path: string; -} - /** Note MIME type set by the explorer on drag. */ const NOTES_DRAG_MIME = "application/x-notes-path"; @@ -267,7 +260,7 @@ function center(node: CanvasNode): { x: number; y: number } { return { x: node.x + node.width / 2, y: node.y + node.height / 2 }; } -export function CanvasView({ value, onChange, callbacks, path }: CanvasViewProps) { +export function CanvasView({ value, onChange, callbacks, path }: RendererProps) { const { openPrompt, promptDialog } = usePromptDialog(); const undoStack = useUndoStack(); const [data, setData] = useState(() => parseCanvas(value)); diff --git a/packages/note-grid/src/grid-note-type.ts b/packages/note-grid/src/grid-note-type.ts index 4b2836a..73014d7 100644 --- a/packages/note-grid/src/grid-note-type.ts +++ b/packages/note-grid/src/grid-note-type.ts @@ -1,10 +1,11 @@ -import type { NoteTypeProvider, NoteViewRegistry, NoteViewDisposer } from "@notes/core"; +import type { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeViewDescriptor } from "@notes/editor"; import { GridView } from "./grid-view"; export const GRID_NOTE_TYPE_ID = "grid"; /** Grid notes are `.md` files with `type: grid` frontmatter. */ -export const gridNoteType: NoteTypeProvider = { +export const gridNoteType: NoteTypeViewDescriptor = { id: GRID_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "grid"; @@ -15,7 +16,7 @@ export const gridNoteType: NoteTypeProvider = { viewComponent: GridView, }; -/** Registers the grid note type with the NoteViewRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteView(registry: NoteViewRegistry): NoteViewDisposer { +/** Registers the grid note type with the NoteTypeRegistry — mirrors the plugin pattern. */ +export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { return registry.register(gridNoteType); } diff --git a/packages/note-grid/src/grid-view.tsx b/packages/note-grid/src/grid-view.tsx index b34c7c8..7d7e9d1 100644 --- a/packages/note-grid/src/grid-view.tsx +++ b/packages/note-grid/src/grid-view.tsx @@ -1,6 +1,6 @@ import { useUndoStack } from "@notes/web/src/state/undo-context"; import { useEffect, useRef, useState } from "react"; -import { NoteToolbar, usePromptDialog } from "@notes/editor"; +import { NoteToolbar, usePromptDialog, type RendererProps } from "@notes/editor"; import { cellKey, newId, @@ -10,11 +10,6 @@ import { type GridModel, } from "./grid-format"; -interface GridViewProps { - value: string; - onChange: (markdown: string) => void; -} - type Tool = "paint" | "erase" | "fill" | "token"; const PALETTE = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#a855f7", "#111827", "#ffffff"]; @@ -32,7 +27,7 @@ function topColor(model: GridModel, x: number, y: number): string | undefined { return color; } -export function GridView({ value, onChange }: GridViewProps) { +export function GridView({ value, onChange }: RendererProps) { const { openPrompt, promptDialog } = usePromptDialog(); const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseGrid(value)); diff --git a/packages/note-mermaid/src/mermaid-note-type.ts b/packages/note-mermaid/src/mermaid-note-type.ts index 8fe2be3..8b30250 100644 --- a/packages/note-mermaid/src/mermaid-note-type.ts +++ b/packages/note-mermaid/src/mermaid-note-type.ts @@ -1,10 +1,11 @@ -import type { NoteTypeProvider, NoteViewRegistry, NoteViewDisposer } from "@notes/core"; +import type { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeViewDescriptor } from "@notes/editor"; import { MermaidView } from "./mermaid-view"; export const MERMAID_NOTE_TYPE_ID = "mermaid"; /** Mermaid notes are `.md` files with `type: mermaid` frontmatter. */ -export const mermaidNoteType: NoteTypeProvider = { +export const mermaidNoteType: NoteTypeViewDescriptor = { id: MERMAID_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "mermaid"; @@ -15,7 +16,7 @@ export const mermaidNoteType: NoteTypeProvider = { viewComponent: MermaidView, }; -/** Registers the mermaid note type with the NoteViewRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteView(registry: NoteViewRegistry): NoteViewDisposer { +/** Registers the mermaid note type with the NoteTypeRegistry — mirrors the plugin pattern. */ +export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { return registry.register(mermaidNoteType); } diff --git a/packages/note-mermaid/src/mermaid-view.tsx b/packages/note-mermaid/src/mermaid-view.tsx index 6bbbe48..9b00b15 100644 --- a/packages/note-mermaid/src/mermaid-view.tsx +++ b/packages/note-mermaid/src/mermaid-view.tsx @@ -1,13 +1,9 @@ import { useEffect, useRef, useState } from "react"; +import type { RendererProps } from "@notes/editor"; import { MermaidDiagram } from "./mermaid-diagram"; import { parseMermaid, type MermaidModel } from "./mermaid-format"; -interface MermaidViewProps { - value: string; - onChange: (markdown: string) => void; -} - -export function MermaidView({ value }: MermaidViewProps) { +export function MermaidView({ value }: RendererProps) { const [model, setModel] = useState(() => parseMermaid(value)); const lastSerialized = useRef(value); diff --git a/packages/note-tables/src/table-note-type.ts b/packages/note-tables/src/table-note-type.ts index f9d6fcb..a70d866 100644 --- a/packages/note-tables/src/table-note-type.ts +++ b/packages/note-tables/src/table-note-type.ts @@ -1,10 +1,11 @@ -import type { NoteTypeProvider, NoteViewRegistry, NoteViewDisposer } from "@notes/core"; +import type { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeViewDescriptor } from "@notes/editor"; import { TableGrid } from "./table-grid"; export const TABLE_NOTE_TYPE_ID = "table"; /** Table notes are `.md` files with `type: table` frontmatter. */ -export const tableNoteType: NoteTypeProvider = { +export const tableNoteType: NoteTypeViewDescriptor = { id: TABLE_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "table"; @@ -15,7 +16,7 @@ export const tableNoteType: NoteTypeProvider = { viewComponent: TableGrid, }; -/** Registers the table note type with the NoteViewRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteView(registry: NoteViewRegistry): NoteViewDisposer { +/** Registers the table note type with the NoteTypeRegistry — mirrors the plugin pattern. */ +export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { return registry.register(tableNoteType); } diff --git a/packages/plugin-host/src/context.ts b/packages/plugin-host/src/context.ts index 3918366..84546a8 100644 --- a/packages/plugin-host/src/context.ts +++ b/packages/plugin-host/src/context.ts @@ -1,4 +1,4 @@ -import type { NoteTypeProvider } from "@notes/core"; +import type { NoteTypeDescriptor } from "@notes/core"; import type { PluginManifest } from "./manifest"; export type Disposer = () => void; @@ -96,7 +96,7 @@ export interface PluginContext { * toolbar items, context menu, mode restrictions, scroll sync, etc.). * Returns a disposer that unregisters the view when the plugin is disabled. */ - registerNoteView: (descriptor: NoteTypeProvider) => Disposer; + registerNoteType: (descriptor: NoteTypeDescriptor) => Disposer; readonly document: DocumentSignal; readonly settings: PluginSettings; } @@ -113,7 +113,7 @@ export interface PluginHost { addStatusBarItem: (item: StatusBarItem) => Disposer; setThemeToken: (name: string, value: string) => Disposer; registerFileHandler: (handler: FileTypeHandler) => Disposer; - registerNoteView: (descriptor: NoteTypeProvider) => Disposer; + registerNoteType: (descriptor: NoteTypeDescriptor) => Disposer; document: DocumentSignal; storage: { getItem: (key: string) => string | null; diff --git a/packages/plugin-host/src/plugin-manager.test.ts b/packages/plugin-host/src/plugin-manager.test.ts index a0aecc8..e385d66 100644 --- a/packages/plugin-host/src/plugin-manager.test.ts +++ b/packages/plugin-host/src/plugin-manager.test.ts @@ -32,7 +32,7 @@ function makeHost() { fileHandlers.push(handler); return () => fileHandlers.splice(fileHandlers.indexOf(handler), 1); }, - registerNoteView: () => { + registerNoteType: () => { return () => {}; }, document: new Signal(null), diff --git a/packages/plugin-host/src/plugin-manager.ts b/packages/plugin-host/src/plugin-manager.ts index 718a66d..4d3355a 100644 --- a/packages/plugin-host/src/plugin-manager.ts +++ b/packages/plugin-host/src/plugin-manager.ts @@ -111,8 +111,8 @@ export class PluginManager { entry.disposers.push(disposer); return disposer; }, - registerNoteView: (descriptor) => { - const disposer = this.host.registerNoteView(descriptor); + registerNoteType: (descriptor) => { + const disposer = this.host.registerNoteType(descriptor); entry.disposers.push(disposer); return disposer; }, From caa1a29d0c462c0f85498fb81ca101d250b5653a Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Mon, 10 Aug 2026 22:42:14 -0500 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20one=20NoteTypeDescriptor=20?= =?UTF-8?q?=E2=80=94=20collapse=20NoteTypeViewDescriptor;=20move=20full=20?= =?UTF-8?q?type=20to=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous split had NoteTypeDescriptor (core, unknown fields) and NoteTypeViewDescriptor (editor, typed fields) — two interfaces for the same concept, and a correspondingly split markdownNoteType / registerMarkdownNoteType. ## New structure packages/core: NoteTypeDetector { id, detect } — minimal interface for server-side detection NoteTypeRegistry — generic; default T = NoteTypeDetector packages/editor: NoteTypeDescriptor extends NoteTypeDetector — THE complete descriptor; no opaque fields, all React types are concrete (viewComponent: ComponentType, toolbarItems: NoteTypeToolbarItem[], contextMenuBuilder: NoteViewContextMenuBuilder) NoteTypeToolbarItem — moved here from core (now properly typed with ReactNode) EditorMode defined directly (no NoteViewMode alias from core) ## Consequences - NoteTypeViewDescriptor deleted entirely — it IS NoteTypeDescriptor now - markdownNoteType in core is a bare NoteTypeDetector (id + detect only) - markdownDescriptor in packages/editor is the complete descriptor, including viewComponent: RenderedEditor, supportedModes, supportsScrollSync — one place - registerMarkdownNoteType accepts NoteTypeRegistry - All note-* packages import NoteTypeDescriptor from @notes/editor - NoteTypeRegistry used client-side; server still uses the untyped default (NoteTypeRegistry == NoteTypeRegistry) - Accessor helpers (getNoteViewComponent etc.) simplified: no casts needed since fields are already properly typed in NoteTypeDescriptor All 156 tests pass; typecheck clean; lint clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../server/src/commands/note-type-commands.ts | 3 +- apps/web/src/app.tsx | 39 ++++----- apps/web/src/components/embed-widget.tsx | 2 +- apps/web/src/components/note-editor.tsx | 32 ++++---- apps/web/src/state/app-services.tsx | 5 +- apps/web/src/state/use-plugins.ts | 3 +- packages/core/src/contracts.ts | 57 ++----------- packages/core/src/index.ts | 1 - packages/core/src/markdown-note-type.ts | 16 ---- packages/core/src/note-type-registry.test.ts | 20 +++-- packages/core/src/note-type-registry.ts | 25 +++--- packages/core/src/note-view-registry.ts | 2 - packages/editor/src/index.ts | 2 +- packages/editor/src/markdown-note-type.ts | 16 ++++ packages/editor/src/markdown-note-view.ts | 22 ------ packages/editor/src/native-source-editor.tsx | 60 +++----------- packages/editor/src/note-view-descriptor.ts | 79 +++++++++++-------- packages/editor/src/rendered-editor.tsx | 54 +++---------- packages/editor/src/types.ts | 5 +- packages/note-boards/src/board-note-type.ts | 10 +-- .../note-calendar/src/calendar-note-type.ts | 10 +-- packages/note-canvas/src/canvas-note-type.ts | 10 +-- packages/note-grid/src/grid-note-type.ts | 10 +-- .../note-mermaid/src/mermaid-note-type.ts | 10 +-- packages/note-tables/src/table-grid.tsx | 9 +-- packages/note-tables/src/table-note-type.ts | 10 +-- packages/plugin-host/src/context.ts | 6 +- 27 files changed, 179 insertions(+), 339 deletions(-) delete mode 100644 packages/core/src/markdown-note-type.ts delete mode 100644 packages/core/src/note-view-registry.ts create mode 100644 packages/editor/src/markdown-note-type.ts delete mode 100644 packages/editor/src/markdown-note-view.ts diff --git a/apps/server/src/commands/note-type-commands.ts b/apps/server/src/commands/note-type-commands.ts index 0c6985f..cc3fe60 100644 --- a/apps/server/src/commands/note-type-commands.ts +++ b/apps/server/src/commands/note-type-commands.ts @@ -1,4 +1,5 @@ -import { markdownNoteType, NoteTypeRegistry, type CommandBus } from "@notes/core"; +import { NoteTypeRegistry, type CommandBus } from "@notes/core"; +import { markdownNoteType } from "@notes/editor"; import { boardNoteType } from "@notes/note-boards"; import { calendarNoteType } from "@notes/note-calendar"; import { canvasNoteType } from "@notes/note-canvas"; diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index eb6de1a..225274c 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,21 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { NoteTypeRegistry, formatCombo } from "@notes/core"; -import { registerMarkdownNoteType } from "@notes/editor"; -import { emptyCanvas, registerBuiltinNoteType as registerCanvasNoteType } from "@notes/note-canvas"; -import { emptyBoard, registerBuiltinNoteType as registerBoardNoteType } from "@notes/note-boards"; -import { - emptyCalendar, - registerBuiltinNoteType as registerCalendarNoteType, -} from "@notes/note-calendar"; -import { emptyGrid, registerBuiltinNoteType as registerGridNoteType } from "@notes/note-grid"; -import { - emptyMermaid, - registerBuiltinNoteType as registerMermaidNoteType, -} from "@notes/note-mermaid"; -import { - emptyTableMarkdown, - registerBuiltinNoteType as registerTableNoteType, -} from "@notes/note-tables"; +import { type NoteTypeDescriptor, markdownNoteType } from "@notes/editor"; +import { canvasNoteType, emptyCanvas } from "@notes/note-canvas"; +import { boardNoteType, emptyBoard } from "@notes/note-boards"; +import { calendarNoteType, emptyCalendar } from "@notes/note-calendar"; +import { emptyGrid, gridNoteType } from "@notes/note-grid"; +import { emptyMermaid, mermaidNoteType } from "@notes/note-mermaid"; +import { emptyTableMarkdown, tableNoteType } from "@notes/note-tables"; import type { PluginManifest } from "@notes/plugin-host"; import type { ThemeMeta } from "@notes/shared"; import { api, type FileEntry } from "./api/client"; @@ -133,14 +124,14 @@ export function App() { // Kept here (not in usePlugins) so the registry is an explicit app-level // concern; plugins extend it via PluginContext.registerNoteType. const noteTypeRegistry = useMemo(() => { - const registry = new NoteTypeRegistry(); - registerMarkdownNoteType(registry); - registerCanvasNoteType(registry); - registerBoardNoteType(registry); - registerTableNoteType(registry); - registerMermaidNoteType(registry); - registerCalendarNoteType(registry); - registerGridNoteType(registry); + const registry = new NoteTypeRegistry(); + registry.register(markdownNoteType, { fallback: true }); + registry.register(canvasNoteType); + registry.register(boardNoteType); + registry.register(tableNoteType); + registry.register(mermaidNoteType); + registry.register(calendarNoteType); + registry.register(gridNoteType); return registry; }, []); diff --git a/apps/web/src/components/embed-widget.tsx b/apps/web/src/components/embed-widget.tsx index 0f649e7..5f3bae2 100644 --- a/apps/web/src/components/embed-widget.tsx +++ b/apps/web/src/components/embed-widget.tsx @@ -177,7 +177,7 @@ export function EmbedWidget({ target }: { target: string }) { case "grid": return ; case "table": - return ; + return ; case "board": case "calendar": return ( diff --git a/apps/web/src/components/note-editor.tsx b/apps/web/src/components/note-editor.tsx index 426bb97..0bb5ec7 100644 --- a/apps/web/src/components/note-editor.tsx +++ b/apps/web/src/components/note-editor.tsx @@ -1,4 +1,3 @@ -import { MARKDOWN_NOTE_TYPE_ID } from "@notes/core"; import { DEFAULT_MARKDOWN_VIEW_STATE, EDITOR_MODES, @@ -6,6 +5,7 @@ import { type EditorMode, type MarkdownViewState, type MarkdownPane, + MARKDOWN_NOTE_TYPE_ID, NativeSourceEditor, NoteToolbar, PaneSyncProvider, @@ -188,18 +188,22 @@ export function NoteEditor({ return activePaneRef.current; }, []); + const { send: sendSourceFocus } = sourceFocus; + const { send: sendRenderedFocus } = renderedFocus; const requestPaneFocus = useCallback( (pane: MarkdownPane) => { - if (pane === "source") sourceFocus.send(); - else renderedFocus.send(); + if (pane === "source") sendSourceFocus(); + else sendRenderedFocus(); }, - [sourceFocus, renderedFocus], + [sendSourceFocus, sendRenderedFocus], ); useEffect(() => { requestPaneFocus(preferredPaneForMode(mode)); }, [mode, preferredPaneForMode, requestPaneFocus]); + const { send: sendSourceCursor } = sourceCursor; + const { send: sendRenderedCursor } = renderedCursor; useEffect(() => { const prev = prevModeRef.current; if (prev === mode) return; @@ -209,21 +213,21 @@ export function NoteEditor({ activePaneRef.current === "rendered" ? renderedCursorPosRef.current : sourceCursorPosRef.current; - sourceCursor.send(position); + sendSourceCursor(position); requestPaneFocus("source"); } else if (mode === "rendered") { const position = activePaneRef.current === "source" ? sourceCursorPosRef.current : renderedCursorPosRef.current; - renderedCursor.send(position); + sendRenderedCursor(position); requestPaneFocus("rendered"); } else { requestPaneFocus(preferredPaneForMode(mode)); } prevModeRef.current = mode; - }, [mode, preferredPaneForMode, requestPaneFocus, sourceCursor, renderedCursor]); + }, [mode, preferredPaneForMode, requestPaneFocus, sendSourceCursor, sendRenderedCursor]); const emitViewState = useCallback( (patch: Partial) => { @@ -262,6 +266,7 @@ export function NoteEditor({ [emitViewState], ); + const { send: sendRenderedScroll } = renderedScroll; const handleSourceScrollChange = useCallback( (ratio: number) => { emitViewState({ sourceScrollRatio: ratio }); @@ -271,11 +276,12 @@ export function NoteEditor({ return; } renderedScrollLock.current = true; - renderedScroll.send(ratio); + sendRenderedScroll(ratio); }, - [emitViewState, mode, splitScrollSync, renderedScroll], + [emitViewState, mode, splitScrollSync, sendRenderedScroll], ); + const { send: sendSourceScroll } = sourceScroll; const handleRenderedScrollChange = useCallback( (ratio: number) => { emitViewState({ renderedScrollRatio: ratio }); @@ -285,9 +291,9 @@ export function NoteEditor({ return; } sourceScrollLock.current = true; - sourceScroll.send(ratio); + sendSourceScroll(ratio); }, - [emitViewState, mode, splitScrollSync, sourceScroll], + [emitViewState, mode, splitScrollSync, sendSourceScroll], ); // Flush any unsaved edit when the component unmounts (tab switch / close). @@ -595,7 +601,7 @@ export function NoteEditor({
{showSource && (
- +
)} {showRendered && noteRenderer && ( @@ -604,8 +610,6 @@ export function NoteEditor({ path={path} value={content} onChange={handleChange} - callbacks={callbacks} - isStandalone={isStandalone} onRegisterContextMenu={(builder) => setNoteViewCtxBuilder(builder as NoteViewContextMenuBuilder | null) } diff --git a/apps/web/src/state/app-services.tsx b/apps/web/src/state/app-services.tsx index 9853dc0..746971e 100644 --- a/apps/web/src/state/app-services.tsx +++ b/apps/web/src/state/app-services.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, type ReactNode } from "react"; import type { FileTypeHandler } from "@notes/plugin-host"; import { NoteTypeRegistry } from "@notes/core"; +import type { NoteTypeDescriptor } from "@notes/editor"; import type { SettingsBodyProps } from "../components/settings-view"; import type { UndoableFileOps } from "../api/undoable-file-ops"; @@ -27,7 +28,7 @@ export interface AppServices { /** Plugin-registered file-type handlers, keyed by file extension. */ fileHandlers: FileTypeHandler[]; /** Registry of note-type descriptors (built-ins + plugin-registered). */ - noteTypeRegistry: NoteTypeRegistry; + noteTypeRegistry: NoteTypeRegistry; /** Everything the settings surface needs, so a settings tab can render. */ settings: SettingsBodyProps; /** File-level undoable operations (create, delete, rename). */ @@ -91,7 +92,7 @@ const AppServicesContext = createContext({ noteTypes: {}, setActiveDocument: noop, fileHandlers: [], - noteTypeRegistry: new NoteTypeRegistry(), + noteTypeRegistry: new NoteTypeRegistry(), settings: defaultSettings, undoableFileOps: { createFile: async () => {}, diff --git a/apps/web/src/state/use-plugins.ts b/apps/web/src/state/use-plugins.ts index 242f4e2..51ee51f 100644 --- a/apps/web/src/state/use-plugins.ts +++ b/apps/web/src/state/use-plugins.ts @@ -10,6 +10,7 @@ import { type StatusBarItem, } from "@notes/plugin-host"; import { type NoteTypeRegistry } from "@notes/core"; +import { type NoteTypeDescriptor } from "@notes/editor"; import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { localPlugins } from "../plugins"; @@ -48,7 +49,7 @@ async function loadTomePlugin(id: string): Promise { } } -export function usePlugins(noteTypeRegistry: NoteTypeRegistry): PluginsApi { +export function usePlugins(noteTypeRegistry: NoteTypeRegistry): PluginsApi { const documentSignal = useState(() => new Signal(null))[0]; const [pluginCommands, setPluginCommands] = useState([]); const [statusItems, setStatusItems] = useState([]); diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index e5faecd..b5c037a 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -44,59 +44,14 @@ export interface NoteFileDescriptor { readonly frontmatterType?: string; } -/** The editor modes a note-type view may support. */ -export type NoteViewMode = "edit" | "split" | "rendered"; - /** - * A toolbar item contributed by a note type. The `element` field is opaque - * here (unknown) and narrowed to ReactNode by the editor layer. + * Minimal interface for note-type detection — used server-side to match a + * file to its type. Only carries identity and the detect predicate. + * + * The full note-type descriptor (with React view capabilities) is + * NoteTypeDescriptor in packages/editor, which extends this interface. */ -export interface NoteTypeToolbarItem { - /** Unique id, e.g. "canvas.zoom-in". */ - id: string; - /** - * When set, replaces the built-in toolbar button with this id. - * When omitted, the item is appended after the built-in buttons. - */ - replace?: string; - /** Opaque ReactNode resolved by the editor layer. */ - element: unknown; -} - -/** Strategy/Factory provider for a note type (markdown, table, canvas, board, …). */ -export interface NoteTypeDescriptor { +export interface NoteTypeDetector { readonly id: string; detect(file: NoteFileDescriptor): boolean; - - // ── UI capabilities (all optional) ───────────────────────────── - /** - * Which editor modes this note type supports. - * Defaults to all three: ["edit", "split", "rendered"]. - */ - supportedModes?: NoteViewMode[]; - /** - * When true, the source pane displays raw text as read-only unless the user - * explicitly unlocks it (appropriate for canvas, table, board notes). - */ - sourceProtected?: boolean; - /** - * Whether this type supports scroll / cursor / focus synchronisation between - * source and rendered panes in split mode. Defaults to false. - */ - supportsScrollSync?: boolean; - /** - * Opaque view-component token. The editor package resolves this to a - * React ComponentType via the NoteTypeRegistry. - */ - viewComponent?: unknown; - /** - * Toolbar items contributed by this note type. Each entry may append a new - * button or replace an existing built-in button by id. - */ - toolbarItems?: NoteTypeToolbarItem[]; - /** - * Context-menu item builder. Opaque here; typed in the editor / ui layer. - * Receives the clicked HTMLElement and returns ContextMenuEntry[]. - */ - contextMenuBuilder?: unknown; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 249947a..69b6b41 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,6 @@ export * from "./event-bus"; export * from "./registry"; export * from "./command-bus"; export * from "./note-type-registry"; -export * from "./markdown-note-type"; export * from "./hotkeys"; export * from "./undo-stack"; export * from "./debounce"; diff --git a/packages/core/src/markdown-note-type.ts b/packages/core/src/markdown-note-type.ts deleted file mode 100644 index 02bebd8..0000000 --- a/packages/core/src/markdown-note-type.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { NoteFileDescriptor, NoteTypeDescriptor } from "./contracts"; - -export const MARKDOWN_NOTE_TYPE_ID = "markdown"; - -/** Default note type — any `.md` file that isn't claimed by a more specific type. */ -export const markdownNoteType: NoteTypeDescriptor = { - id: MARKDOWN_NOTE_TYPE_ID, - detect(file: NoteFileDescriptor): boolean { - return file.path.toLowerCase().endsWith(".md"); - }, - supportedModes: ["edit", "split", "rendered"], - sourceProtected: false, - supportsScrollSync: true, - // viewComponent is set by packages/editor via registerBuiltinNoteType - // to avoid a circular dependency (core must stay React-free). -}; diff --git a/packages/core/src/note-type-registry.test.ts b/packages/core/src/note-type-registry.test.ts index 1569cb3..85bbcc2 100644 --- a/packages/core/src/note-type-registry.test.ts +++ b/packages/core/src/note-type-registry.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; -import type { NoteTypeDescriptor } from "./contracts"; -import { markdownNoteType, MARKDOWN_NOTE_TYPE_ID } from "./markdown-note-type"; +import type { NoteTypeDetector } from "./contracts"; import { NoteTypeRegistry } from "./note-type-registry"; -const tableType: NoteTypeDescriptor = { +const testNoteType: NoteTypeDetector = { + id: "test", + detect: (file) => file.path.toLowerCase().endsWith(".test"), +}; + +const tableType: NoteTypeDetector = { id: "table", detect: (file) => file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "table", }; @@ -11,17 +15,17 @@ const tableType: NoteTypeDescriptor = { describe("NoteTypeRegistry", () => { it("falls back to markdown for a plain .md file", () => { const registry = new NoteTypeRegistry(); - registry.register(markdownNoteType, { fallback: true }); + registry.register(testNoteType, { fallback: true }); registry.register(tableType); - const provider = registry.detect({ path: "notes/hello.md" }); + const provider = registry.detect({ path: "notes/hello.test" }); - expect(provider?.id).toBe(MARKDOWN_NOTE_TYPE_ID); + expect(provider?.id).toBe("test"); }); it("prefers a specific provider over the markdown fallback", () => { const registry = new NoteTypeRegistry(); - registry.register(markdownNoteType, { fallback: true }); + registry.register(testNoteType, { fallback: true }); registry.register(tableType); const provider = registry.detect({ path: "data/people.md", frontmatterType: "table" }); @@ -31,7 +35,7 @@ describe("NoteTypeRegistry", () => { it("returns undefined when nothing matches", () => { const registry = new NoteTypeRegistry(); - registry.register(markdownNoteType, { fallback: true }); + registry.register(testNoteType, { fallback: true }); expect(registry.detect({ path: "diagram.canvas" })).toBeUndefined(); }); diff --git a/packages/core/src/note-type-registry.ts b/packages/core/src/note-type-registry.ts index 4047962..117a048 100644 --- a/packages/core/src/note-type-registry.ts +++ b/packages/core/src/note-type-registry.ts @@ -1,25 +1,24 @@ -import type { NoteFileDescriptor, NoteTypeDescriptor } from "./contracts"; +import type { NoteFileDescriptor, NoteTypeDetector } from "./contracts"; import { Registry } from "./registry"; /** - * Unified registry for note types — stores the full NoteTypeDescriptor - * for every registered note type. + * Registry for note types. * - * - Detection (`detect`) is used server-side to match a file to its type. - * - View capabilities (`viewComponent`, `supportedModes`, …) are used - * client-side; the optional fields are simply absent in server-only usage. + * The generic parameter T lets server code work with the minimal NoteTypeDetector + * while client code uses the full NoteTypeDescriptor (from packages/editor). + * Both share this class; the server simply ignores the extra view fields. * - * A single provider may be marked as the fallback (matched last). + * A single entry may be marked as the fallback (matched last). */ -export class NoteTypeRegistry { - private readonly providers = new Registry(); +export class NoteTypeRegistry { + private readonly providers = new Registry(); private fallbackId?: string; /** * Registers a note-type descriptor. Returns a disposer that unregisters it; * callers that do not need to unregister may ignore the return value. */ - register(descriptor: NoteTypeDescriptor, options?: { fallback?: boolean }): () => void { + register(descriptor: T, options?: { fallback?: boolean }): () => void { this.providers.register(descriptor.id, descriptor); if (options?.fallback) { this.fallbackId = descriptor.id; @@ -32,15 +31,15 @@ export class NoteTypeRegistry { }; } - get(id: string): NoteTypeDescriptor | undefined { + get(id: string): T | undefined { return this.providers.get(id); } - list(): NoteTypeDescriptor[] { + list(): T[] { return this.providers.list(); } - detect(file: NoteFileDescriptor): NoteTypeDescriptor | undefined { + detect(file: NoteFileDescriptor): T | undefined { for (const provider of this.providers.list()) { if (provider.id === this.fallbackId) { continue; diff --git a/packages/core/src/note-view-registry.ts b/packages/core/src/note-view-registry.ts deleted file mode 100644 index 70675e7..0000000 --- a/packages/core/src/note-view-registry.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Merged into NoteTypeRegistry. This file is kept only as a transitional shim. -export { NoteTypeRegistry } from "./note-type-registry"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 9b40097..6aa08dd 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,5 +1,5 @@ export * from "./types"; -export * from "./markdown-note-view"; +export * from "./markdown-note-type"; export * from "./note-view-descriptor"; export * from "./pane-sync-context"; export * from "./use-pane-sync"; diff --git a/packages/editor/src/markdown-note-type.ts b/packages/editor/src/markdown-note-type.ts new file mode 100644 index 0000000..c831dca --- /dev/null +++ b/packages/editor/src/markdown-note-type.ts @@ -0,0 +1,16 @@ +import type { NoteTypeDescriptor } from "@notes/editor"; +import { RenderedEditor } from "./rendered-editor"; + +export const MARKDOWN_NOTE_TYPE_ID = "markdown"; + +/** Complete descriptor for the built-in markdown note type. */ +export const markdownNoteType: NoteTypeDescriptor = { + id: MARKDOWN_NOTE_TYPE_ID, + detect(file) { + return file.path.toLowerCase().endsWith(".md"); + }, + supportedModes: ["edit", "split", "rendered"], + sourceProtected: false, + supportsScrollSync: true, + viewComponent: RenderedEditor, +}; diff --git a/packages/editor/src/markdown-note-view.ts b/packages/editor/src/markdown-note-view.ts deleted file mode 100644 index 9efee9c..0000000 --- a/packages/editor/src/markdown-note-view.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Registers the built-in markdown note type with the NoteTypeRegistry. - * - * This lives in packages/editor (not packages/core) because the markdown - * NoteTypeDescriptor needs to reference RenderedEditor — a React component — - * and packages/core must remain React-free. - */ -import { markdownNoteType, type NoteTypeRegistry } from "@notes/core"; -import { RenderedEditor } from "./rendered-editor"; -import type { NoteTypeViewDescriptor } from "./note-view-descriptor"; - -export function registerMarkdownNoteType(registry: NoteTypeRegistry): () => void { - const descriptor: NoteTypeViewDescriptor = { - id: markdownNoteType.id, - detect: markdownNoteType.detect.bind(markdownNoteType), - supportedModes: markdownNoteType.supportedModes, - sourceProtected: markdownNoteType.sourceProtected, - supportsScrollSync: markdownNoteType.supportsScrollSync, - viewComponent: RenderedEditor, - }; - return registry.register(descriptor); -} diff --git a/packages/editor/src/native-source-editor.tsx b/packages/editor/src/native-source-editor.tsx index 1a8a44e..85a1278 100644 --- a/packages/editor/src/native-source-editor.tsx +++ b/packages/editor/src/native-source-editor.tsx @@ -7,55 +7,21 @@ useEffect, useRef, } from "react"; -import { - droppedPathInsertion, - NOTES_PATH_MIME, - type EditorCallbacks, - type CursorRequest, - type FocusRequest, - type ScrollRequest, -} from "./types"; +import { droppedPathInsertion, NOTES_PATH_MIME, RendererProps } from "./types"; import { useSourcePaneSync } from "./pane-sync-context"; -/** - * When rendered inside a PaneSyncProvider (i.e. within NoteEditor), all sync - * props come from context. When used standalone, props are used as fallbacks. - * Only `value` and `onChange` are always required. - */ -interface NativeSourceEditorProps { - value: string; - onChange: (markdown: string) => void; - // All below are fallbacks for standalone use outside a PaneSyncProvider. - callbacks?: EditorCallbacks; - scrollRequest?: ScrollRequest; - onScrollChange?: (ratio: number) => void; - focusRequest?: FocusRequest; - onFocus?: () => void; - cursorRequest?: CursorRequest; - onCursorChange?: (position: number) => void; -} - -export function NativeSourceEditor({ - value, - onChange, - callbacks: callbacksProp, - scrollRequest: scrollRequestProp, - onScrollChange: onScrollChangeProp, - focusRequest: focusRequestProp, - onFocus: onFocusProp, - cursorRequest: cursorRequestProp, - onCursorChange: onCursorChangeProp, -}: NativeSourceEditorProps) { - // Context wins over props; props are fallbacks for standalone usage. - const ctx = useSourcePaneSync(); - const callbacks = ctx?.callbacks ?? callbacksProp; - const scrollRequest = ctx?.scrollRequest ?? scrollRequestProp; - const onScrollChange = ctx?.onScrollChange ?? onScrollChangeProp; - const focusRequest = ctx?.focusRequest ?? focusRequestProp; - const onFocus = ctx?.onFocus ?? onFocusProp; - const cursorRequest = ctx?.cursorRequest ?? cursorRequestProp; - const onCursorChange = ctx?.onCursorChange ?? onCursorChangeProp; - const effectiveOnChange = ctx?.isReadOnly ? () => {} : onChange; +export function NativeSourceEditor({ value, onChange }: RendererProps) { + const { + callbacks, + scrollRequest, + onScrollChange, + focusRequest, + onFocus, + cursorRequest, + onCursorChange, + isReadOnly, + } = useSourcePaneSync() ?? {}; + const effectiveOnChange = isReadOnly ? () => {} : onChange; const viewRef = useRef(null); const suppressScrollRef = useRef(false); diff --git a/packages/editor/src/note-view-descriptor.ts b/packages/editor/src/note-view-descriptor.ts index b4e8019..ef21971 100644 --- a/packages/editor/src/note-view-descriptor.ts +++ b/packages/editor/src/note-view-descriptor.ts @@ -1,60 +1,73 @@ /** - * React-typed narrowing helpers and typed descriptor interface for note types. + * NoteTypeDescriptor — the complete description of a note type. * - * packages/core is React-free, so NoteTypeDescriptor uses `unknown` for its - * React-specific fields. This module provides: - * - NoteTypeViewDescriptor — a typed variant of NoteTypeDescriptor with - * properly typed viewComponent, toolbarItems, and contextMenuBuilder. - * - Accessor helpers (getNoteViewComponent etc.) for the opaque core fields. + * This is the single interface used everywhere in the editor layer. It extends + * the minimal NoteTypeDetector from packages/core (which only carries id + + * detect for server-side file-type detection) with all client-side view + * capabilities, properly typed with React types. + * + * There is no separate "view descriptor" — every note type is described by + * NoteTypeDescriptor from the moment it is registered. */ -import type { NoteTypeDescriptor, NoteTypeToolbarItem } from "@notes/core"; +import type { NoteTypeDetector } from "@notes/core"; import type { ComponentType, ReactNode } from "react"; import type { NoteViewContextMenuBuilder, ContextMenuEntry } from "@notes/ui"; -import type { RendererProps } from "./types"; +import type { EditorMode, RendererProps } from "./types"; export type NoteViewComponent = ComponentType; -export interface TypedNoteTypeToolbarItem extends Omit { +/** A toolbar item contributed by a note type, with element typed as ReactNode. */ +export interface NoteTypeToolbarItem { + /** Unique id, e.g. "canvas.zoom-in". */ + id: string; + /** + * When set, replaces the built-in toolbar button with this id. + * When omitted, the item is appended after the built-in buttons. + */ + replace?: string; element: ReactNode; } -// Re-export for convenience so consumers don't need to import from both packages. +// Re-export for convenience. export type { NoteViewContextMenuBuilder, ContextMenuEntry }; /** - * NoteTypeDescriptor with React-typed fields. Use this when creating a - * descriptor in note-* packages so TypeScript enforces the correct component - * and toolbar types at registration time. + * Complete descriptor for a note type (id, detect, and all view capabilities). * - * Assignable to NoteTypeDescriptor (the base type), so it can be passed - * directly to NoteTypeRegistry.register(). + * This is the one type to use when defining or registering a note type. + * Register with NoteTypeRegistry client-side, or pass to + * a NoteTypeRegistry server-side where only id + detect are used. */ -export interface NoteTypeViewDescriptor extends Omit< - NoteTypeDescriptor, - "viewComponent" | "toolbarItems" | "contextMenuBuilder" -> { +export interface NoteTypeDescriptor extends NoteTypeDetector { + /** Which editor modes this note type supports. Defaults to all three. */ + supportedModes?: EditorMode[]; + /** + * When true, the source pane shows raw text as read-only unless unlocked + * (appropriate for canvas, table, board notes). + */ + sourceProtected?: boolean; + /** Whether split-mode scroll/cursor/focus synchronisation is meaningful. */ + supportsScrollSync?: boolean; + /** React component that renders or edits this note type. */ viewComponent?: NoteViewComponent; - toolbarItems?: TypedNoteTypeToolbarItem[]; + /** Toolbar items contributed by this note type. */ + toolbarItems?: NoteTypeToolbarItem[]; + /** Builds note-type-specific context menu items on right-click. */ contextMenuBuilder?: NoteViewContextMenuBuilder; } -/** Extract the viewComponent from a descriptor (casts the opaque unknown field). */ -export function getNoteViewComponent( - descriptor: NoteTypeDescriptor, -): NoteViewComponent | undefined { - return descriptor.viewComponent as NoteViewComponent | undefined; +// ── Accessor helpers ────────────────────────────────────────────────────────── + +export function getNoteViewComponent(d: NoteTypeDescriptor): NoteViewComponent | undefined { + return d.viewComponent; } -/** Extract and cast toolbarItems to typed ReactNode elements. */ -export function getNoteViewToolbarItems( - descriptor: NoteTypeDescriptor, -): TypedNoteTypeToolbarItem[] { - return (descriptor.toolbarItems ?? []) as TypedNoteTypeToolbarItem[]; +export function getNoteViewToolbarItems(d: NoteTypeDescriptor): NoteTypeToolbarItem[] { + return d.toolbarItems ?? []; } -/** Extract and cast the opaque contextMenuBuilder to a typed function. */ export function getNoteContextMenuBuilder( - descriptor: NoteTypeDescriptor, + d: NoteTypeDescriptor, ): NoteViewContextMenuBuilder | undefined { - return descriptor.contextMenuBuilder as NoteViewContextMenuBuilder | undefined; + return d.contextMenuBuilder; } diff --git a/packages/editor/src/rendered-editor.tsx b/packages/editor/src/rendered-editor.tsx index 6c1048d..317075d 100644 --- a/packages/editor/src/rendered-editor.tsx +++ b/packages/editor/src/rendered-editor.tsx @@ -23,15 +23,9 @@ import { Embed } from "./embed-extension"; import { ImageNode } from "./image-node"; import { StyledTextMark } from "./styled-text-mark"; import { SuggestionPopup } from "./suggestion-popup"; -import { droppedPathInsertion, NOTES_PATH_MIME } from "./types"; +import { droppedPathInsertion, NOTES_PATH_MIME, RendererProps } from "./types"; import { EditorToolbar } from "./toolbar"; -import type { - CursorRequest, - EditorCallbacks, - FocusRequest, - ScrollRequest, - WikiSuggestion, -} from "./types"; +import type { WikiSuggestion } from "./types"; import { useRenderedPaneSync } from "./pane-sync-context"; import { WikilinkDecorator } from "./wikilink-decorator"; @@ -87,45 +81,23 @@ interface SuggestState { top: number; } -interface RenderedEditorProps { - value: string; - onChange: (markdown: string) => void; - // Fallbacks for standalone use — context wins when both are present. - callbacks?: EditorCallbacks; - isStandalone?: boolean; - toolbarDisabled?: boolean; - cursorRequest?: CursorRequest; - scrollRequest?: ScrollRequest; - onCursorChange?: (position: number) => void; - onScrollChange?: (ratio: number) => void; - onFocus?: () => void; - focusRequest?: FocusRequest; -} - /** WYSIWYG editor (TipTap/ProseMirror): toolbar, clickable wikilinks, and autocomplete. */ export function RenderedEditor({ value, onChange, - callbacks: callbacksProp, - isStandalone: isStandaloneProp, toolbarDisabled = false, - cursorRequest: cursorRequestProp, - scrollRequest: scrollRequestProp, - onCursorChange: onCursorChangeProp, - onScrollChange: onScrollChangeProp, - onFocus: onFocusProp, - focusRequest: focusRequestProp, -}: RenderedEditorProps) { +}: Omit & { toolbarDisabled?: boolean; path?: string }) { // Context wins over props; props are fallbacks for standalone usage. - const ctx = useRenderedPaneSync(); - const callbacks = ctx?.callbacks ?? callbacksProp; - const isStandalone = ctx?.isStandalone ?? isStandaloneProp ?? false; - const cursorRequest = ctx?.cursorRequest ?? cursorRequestProp; - const scrollRequest = ctx?.scrollRequest ?? scrollRequestProp; - const onCursorChange = ctx?.onCursorChange ?? onCursorChangeProp; - const onScrollChange = ctx?.onScrollChange ?? onScrollChangeProp; - const onFocus = ctx?.onFocus ?? onFocusProp; - const focusRequest = ctx?.focusRequest ?? focusRequestProp; + const { + callbacks, + isStandalone = false, + cursorRequest, + scrollRequest, + onCursorChange, + onScrollChange, + onFocus, + focusRequest, + } = useRenderedPaneSync() ?? {}; const { settings } = useAppServices(); const [findOpen, setFindOpen] = useState(false); const currentParts = parseFrontmatter(value); diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index 36e7264..416ee09 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -1,8 +1,7 @@ -import type { NoteViewMode } from "@notes/core"; import type { ReactNode } from "react"; -/** Canonical editor mode — re-exported from @notes/core as NoteViewMode so there is one definition. */ -export type EditorMode = NoteViewMode; +/** The three editor modes available for a note-type view. */ +export type EditorMode = "edit" | "split" | "rendered"; export const EDITOR_MODES: EditorMode[] = ["edit", "split", "rendered"]; diff --git a/packages/note-boards/src/board-note-type.ts b/packages/note-boards/src/board-note-type.ts index aee3375..04b17f5 100644 --- a/packages/note-boards/src/board-note-type.ts +++ b/packages/note-boards/src/board-note-type.ts @@ -1,11 +1,10 @@ -import type { NoteTypeRegistry } from "@notes/core"; -import type { NoteTypeViewDescriptor } from "@notes/editor"; +import type { NoteTypeDescriptor } from "@notes/editor"; import { BoardView } from "./board-view"; export const BOARD_NOTE_TYPE_ID = "board"; /** Board notes are `.md` files with `type: board` frontmatter. */ -export const boardNoteType: NoteTypeViewDescriptor = { +export const boardNoteType: NoteTypeDescriptor = { id: BOARD_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "board"; @@ -15,8 +14,3 @@ export const boardNoteType: NoteTypeViewDescriptor = { supportsScrollSync: false, viewComponent: BoardView, }; - -/** Registers the board note type with the NoteTypeRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { - return registry.register(boardNoteType); -} diff --git a/packages/note-calendar/src/calendar-note-type.ts b/packages/note-calendar/src/calendar-note-type.ts index 3ea55e4..b661238 100644 --- a/packages/note-calendar/src/calendar-note-type.ts +++ b/packages/note-calendar/src/calendar-note-type.ts @@ -1,11 +1,10 @@ -import type { NoteTypeRegistry } from "@notes/core"; -import type { NoteTypeViewDescriptor } from "@notes/editor"; +import type { NoteTypeDescriptor } from "@notes/editor"; import { CalendarView } from "./calendar-view"; export const CALENDAR_NOTE_TYPE_ID = "calendar"; /** Calendar notes are `.md` files with `type: calendar` frontmatter. */ -export const calendarNoteType: NoteTypeViewDescriptor = { +export const calendarNoteType: NoteTypeDescriptor = { id: CALENDAR_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "calendar"; @@ -15,8 +14,3 @@ export const calendarNoteType: NoteTypeViewDescriptor = { supportsScrollSync: false, viewComponent: CalendarView, }; - -/** Registers the calendar note type with the NoteTypeRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { - return registry.register(calendarNoteType); -} diff --git a/packages/note-canvas/src/canvas-note-type.ts b/packages/note-canvas/src/canvas-note-type.ts index 51d6c0e..69cbdd6 100644 --- a/packages/note-canvas/src/canvas-note-type.ts +++ b/packages/note-canvas/src/canvas-note-type.ts @@ -1,11 +1,10 @@ -import type { NoteTypeRegistry } from "@notes/core"; -import type { NoteTypeViewDescriptor } from "@notes/editor"; +import type { NoteTypeDescriptor } from "@notes/editor"; import { CanvasView } from "./canvas-view"; export const CANVAS_NOTE_TYPE_ID = "canvas"; /** Canvas notes are `.canvas` files (JSONCanvas). */ -export const canvasNoteType: NoteTypeViewDescriptor = { +export const canvasNoteType: NoteTypeDescriptor = { id: CANVAS_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".canvas"); @@ -15,8 +14,3 @@ export const canvasNoteType: NoteTypeViewDescriptor = { supportsScrollSync: false, viewComponent: CanvasView, }; - -/** Registers the canvas note type with the NoteTypeRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { - return registry.register(canvasNoteType); -} diff --git a/packages/note-grid/src/grid-note-type.ts b/packages/note-grid/src/grid-note-type.ts index 73014d7..f746382 100644 --- a/packages/note-grid/src/grid-note-type.ts +++ b/packages/note-grid/src/grid-note-type.ts @@ -1,11 +1,10 @@ -import type { NoteTypeRegistry } from "@notes/core"; -import type { NoteTypeViewDescriptor } from "@notes/editor"; +import type { NoteTypeDescriptor } from "@notes/editor"; import { GridView } from "./grid-view"; export const GRID_NOTE_TYPE_ID = "grid"; /** Grid notes are `.md` files with `type: grid` frontmatter. */ -export const gridNoteType: NoteTypeViewDescriptor = { +export const gridNoteType: NoteTypeDescriptor = { id: GRID_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "grid"; @@ -15,8 +14,3 @@ export const gridNoteType: NoteTypeViewDescriptor = { supportsScrollSync: false, viewComponent: GridView, }; - -/** Registers the grid note type with the NoteTypeRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { - return registry.register(gridNoteType); -} diff --git a/packages/note-mermaid/src/mermaid-note-type.ts b/packages/note-mermaid/src/mermaid-note-type.ts index 8b30250..c518497 100644 --- a/packages/note-mermaid/src/mermaid-note-type.ts +++ b/packages/note-mermaid/src/mermaid-note-type.ts @@ -1,11 +1,10 @@ -import type { NoteTypeRegistry } from "@notes/core"; -import type { NoteTypeViewDescriptor } from "@notes/editor"; +import type { NoteTypeDescriptor } from "@notes/editor"; import { MermaidView } from "./mermaid-view"; export const MERMAID_NOTE_TYPE_ID = "mermaid"; /** Mermaid notes are `.md` files with `type: mermaid` frontmatter. */ -export const mermaidNoteType: NoteTypeViewDescriptor = { +export const mermaidNoteType: NoteTypeDescriptor = { id: MERMAID_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "mermaid"; @@ -15,8 +14,3 @@ export const mermaidNoteType: NoteTypeViewDescriptor = { supportsScrollSync: false, viewComponent: MermaidView, }; - -/** Registers the mermaid note type with the NoteTypeRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { - return registry.register(mermaidNoteType); -} diff --git a/packages/note-tables/src/table-grid.tsx b/packages/note-tables/src/table-grid.tsx index 9614167..95d34d1 100644 --- a/packages/note-tables/src/table-grid.tsx +++ b/packages/note-tables/src/table-grid.tsx @@ -7,7 +7,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { PopupMenu } from "@notes/ui"; -import { NoteToolbar, usePromptDialog } from "@notes/editor"; +import { NoteToolbar, type RendererProps, usePromptDialog } from "@notes/editor"; import { COLUMN_TYPES, parseTable, @@ -16,11 +16,6 @@ import { type TableModel, } from "./table-format"; -interface TableGridProps { - value: string; - onChange: (markdown: string) => void; -} - interface CellPos { r: number; c: number; @@ -37,7 +32,7 @@ function compareValues(a: string, b: string, type: ColumnType): number { return a.localeCompare(b); } -export function TableGrid({ value, onChange }: TableGridProps) { +export function TableGrid({ value, onChange }: RendererProps) { const { openPrompt, promptDialog } = usePromptDialog(); const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseTable(value)); diff --git a/packages/note-tables/src/table-note-type.ts b/packages/note-tables/src/table-note-type.ts index a70d866..aeb1a91 100644 --- a/packages/note-tables/src/table-note-type.ts +++ b/packages/note-tables/src/table-note-type.ts @@ -1,11 +1,10 @@ -import type { NoteTypeRegistry } from "@notes/core"; -import type { NoteTypeViewDescriptor } from "@notes/editor"; +import type { NoteTypeDescriptor } from "@notes/editor"; import { TableGrid } from "./table-grid"; export const TABLE_NOTE_TYPE_ID = "table"; /** Table notes are `.md` files with `type: table` frontmatter. */ -export const tableNoteType: NoteTypeViewDescriptor = { +export const tableNoteType: NoteTypeDescriptor = { id: TABLE_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "table"; @@ -15,8 +14,3 @@ export const tableNoteType: NoteTypeViewDescriptor = { supportsScrollSync: false, viewComponent: TableGrid, }; - -/** Registers the table note type with the NoteTypeRegistry — mirrors the plugin pattern. */ -export function registerBuiltinNoteType(registry: NoteTypeRegistry): () => void { - return registry.register(tableNoteType); -} diff --git a/packages/plugin-host/src/context.ts b/packages/plugin-host/src/context.ts index 84546a8..e388566 100644 --- a/packages/plugin-host/src/context.ts +++ b/packages/plugin-host/src/context.ts @@ -1,4 +1,4 @@ -import type { NoteTypeDescriptor } from "@notes/core"; +import type { NoteTypeDetector } from "@notes/core"; import type { PluginManifest } from "./manifest"; export type Disposer = () => void; @@ -96,7 +96,7 @@ export interface PluginContext { * toolbar items, context menu, mode restrictions, scroll sync, etc.). * Returns a disposer that unregisters the view when the plugin is disabled. */ - registerNoteType: (descriptor: NoteTypeDescriptor) => Disposer; + registerNoteType: (descriptor: NoteTypeDetector) => Disposer; readonly document: DocumentSignal; readonly settings: PluginSettings; } @@ -113,7 +113,7 @@ export interface PluginHost { addStatusBarItem: (item: StatusBarItem) => Disposer; setThemeToken: (name: string, value: string) => Disposer; registerFileHandler: (handler: FileTypeHandler) => Disposer; - registerNoteType: (descriptor: NoteTypeDescriptor) => Disposer; + registerNoteType: (descriptor: NoteTypeDetector) => Disposer; document: DocumentSignal; storage: { getItem: (key: string) => string | null;