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 affc098..225274c 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { formatCombo } from "@notes/core"; -import { emptyCanvas } from "@notes/note-canvas"; -import { emptyBoard } from "@notes/note-boards"; -import { emptyCalendar } from "@notes/note-calendar"; -import { emptyGrid } from "@notes/note-grid"; -import { emptyMermaid } from "@notes/note-mermaid"; -import { emptyTableMarkdown } from "@notes/note-tables"; +import { NoteTypeRegistry, formatCombo } from "@notes/core"; +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"; @@ -118,7 +119,23 @@ export function App() { const [noteTypes, setNoteTypes] = useState>({}); const [externalThemes, setExternalThemes] = useState([]); const [pendingRestartPlugins, setPendingRestartPlugins] = useState([]); - const plugins = usePlugins(); + + // 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.registerNoteType. + const noteTypeRegistry = useMemo(() => { + 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; + }, []); + + const plugins = usePlugins(noteTypeRegistry); // Paths of freshly-created notes not yet modified/named (discarded on close). const [provisional, setProvisional] = useState>(new Set()); @@ -837,6 +854,7 @@ export function App() { setActiveDocument: (doc: { path: string; content: string; type: string } | null) => plugins.documentSignal.set(doc), fileHandlers: plugins.fileHandlers, + noteTypeRegistry, settings: settingsProps, undoableFileOps, }), @@ -855,6 +873,7 @@ export function App() { noteTypes, plugins.documentSignal, plugins.fileHandlers, + noteTypeRegistry, settingsProps, undoableFileOps, ], diff --git a/apps/web/src/components/embed-widget.tsx b/apps/web/src/components/embed-widget.tsx index fc89eab..5f3bae2 100644 --- a/apps/web/src/components/embed-widget.tsx +++ b/apps/web/src/components/embed-widget.tsx @@ -173,11 +173,11 @@ export function EmbedWidget({ target }: { target: string }) { case "image": return {title}; case "mermaid": - return ; + return ; case "grid": - return ; + return ; case "table": - return ; + return ; case "board": case "calendar": return ( 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" && (
) : ( - { - setMarkdownViewState((prevState) => { - const nextState = { ...prevState, ...patch }; - persistSession(mode, nextState); - return nextState; - }); - }} - isReadOnly={frontType !== MARKDOWN_NOTE_TYPE_ID && frontType !== MERMAID_NOTE_TYPE_ID} - disableToolbarInEdit - isStandalone={isStandalone} - setNoteViewCtxBuilder={setNoteViewCtxBuilder} - /> + +
+ {effectiveMode === "edit" && } +
+ {showSource && ( +
+ +
+ )} + {showRendered && noteRenderer && ( + + setNoteViewCtxBuilder(builder as NoteViewContextMenuBuilder | null) + } + /> + )} + {showRendered && !noteRenderer && ( +
+ No renderer registered for “{frontType}”. +
+ )} +
+
+
)} diff --git a/apps/web/src/state/app-services.tsx b/apps/web/src/state/app-services.tsx index 2293d69..746971e 100644 --- a/apps/web/src/state/app-services.tsx +++ b/apps/web/src/state/app-services.tsx @@ -1,5 +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"; @@ -25,6 +27,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 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). */ @@ -88,6 +92,7 @@ const AppServicesContext = createContext({ noteTypes: {}, setActiveDocument: noop, fileHandlers: [], + noteTypeRegistry: new NoteTypeRegistry(), settings: defaultSettings, undoableFileOps: { createFile: async () => {}, 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/apps/web/src/state/use-plugins.ts b/apps/web/src/state/use-plugins.ts index b0b59be..51ee51f 100644 --- a/apps/web/src/state/use-plugins.ts +++ b/apps/web/src/state/use-plugins.ts @@ -9,7 +9,9 @@ import { type PluginInfo, type StatusBarItem, } from "@notes/plugin-host"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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"; @@ -47,8 +49,8 @@ async function loadTomePlugin(id: string): Promise { } } -export function usePlugins(): PluginsApi { - const documentSignal = useMemo(() => new Signal(null), []); +export function usePlugins(noteTypeRegistry: NoteTypeRegistry): PluginsApi { + const documentSignal = useState(() => new Signal(null))[0]; const [pluginCommands, setPluginCommands] = useState([]); const [statusItems, setStatusItems] = useState([]); const [fileHandlers, setFileHandlers] = useState([]); @@ -81,6 +83,9 @@ export function usePlugins(): PluginsApi { }); return () => setFileHandlers((prev) => prev.filter((h) => h !== handler)); }, + registerNoteType: (descriptor) => { + return noteTypeRegistry.register(descriptor); + }, document: documentSignal, storage: window.localStorage, }; @@ -139,7 +144,7 @@ export function usePlugins(): PluginsApi { setStatusItems([]); setFileHandlers([]); }; - }, [documentSignal]); + }, [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 b69fbaa..b5c037a 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -44,8 +44,14 @@ export interface NoteFileDescriptor { readonly frontmatterType?: string; } -/** Strategy/Factory provider for a note type (markdown, table, canvas, board, …). */ -export interface NoteTypeProvider { +/** + * 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 NoteTypeDetector { readonly id: string; detect(file: NoteFileDescriptor): boolean; } 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 b4195a4..0000000 --- a/packages/core/src/markdown-note-type.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NoteFileDescriptor, NoteTypeProvider } 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 = { - id: MARKDOWN_NOTE_TYPE_ID, - detect(file: NoteFileDescriptor): boolean { - return file.path.toLowerCase().endsWith(".md"); - }, -}; diff --git a/packages/core/src/note-type-registry.test.ts b/packages/core/src/note-type-registry.test.ts index 3c0009c..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 { NoteTypeProvider } 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: NoteTypeProvider = { +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: NoteTypeProvider = { 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 af43b46..117a048 100644 --- a/packages/core/src/note-type-registry.ts +++ b/packages/core/src/note-type-registry.ts @@ -1,38 +1,45 @@ -import type { NoteFileDescriptor, NoteTypeProvider } from "./contracts"; +import type { NoteFileDescriptor, NoteTypeDetector } 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. + * Registry for note types. + * + * 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 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; - 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: T, 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): T | undefined { return this.providers.get(id); } - list(): NoteTypeProvider[] { + list(): T[] { return this.providers.list(); } - detect(file: NoteFileDescriptor): NoteTypeProvider | undefined { + detect(file: NoteFileDescriptor): T | undefined { for (const provider of this.providers.list()) { if (provider.id === this.fallbackId) { continue; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 2924317..6aa08dd 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,5 +1,8 @@ export * from "./types"; -export * from "./markdown-editor"; +export * from "./markdown-note-type"; +export * from "./note-view-descriptor"; +export * from "./pane-sync-context"; +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 deleted file mode 100644 index 24754a8..0000000 --- a/packages/editor/src/markdown-editor.tsx +++ /dev/null @@ -1,361 +0,0 @@ -import { MARKDOWN_NOTE_TYPE_ID } from "@notes/core"; -import { BOARD_NOTE_TYPE_ID, BoardView } from "@notes/note-boards"; -import { CALENDAR_NOTE_TYPE_ID, CalendarView } from "@notes/note-calendar"; -import { CANVAS_NOTE_TYPE_ID, CanvasView } from "@notes/note-canvas"; -import { GRID_NOTE_TYPE_ID, GridView } from "@notes/note-grid"; -import { MERMAID_NOTE_TYPE_ID, MermaidView } from "@notes/note-mermaid"; -import { TABLE_NOTE_TYPE_ID, TableGrid } from "@notes/note-tables"; -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 { - ComponentType, - Dispatch, - SetStateAction, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { RenderedEditor } from "./rendered-editor"; -import { NativeSourceEditor } from "./native-source-editor"; -import { EditorToolbar } from "./toolbar"; -import type { - CursorRequest, - EditorCallbacks, - EditorMode, - FocusRequest, - ScrollRequest, -} from "./types"; - -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; - /** When true, disables all file/note drop-and-paste operations. */ - isStandalone?: boolean; - isReadOnly?: boolean; - setNoteViewCtxBuilder?: Dispatch>; -} - -interface RendererProps { - path: string; - value: string; - onChange: (markdown: string) => void; - callbacks?: EditorCallbacks; - isStandalone?: boolean; // If the file belongs to the tome or not - cursorRequest?: CursorRequest; - scrollRequest?: ScrollRequest; - onCursorChange?: (position: number) => void; - onScrollChange?: (ratio: number) => void; - onFocus?: () => void; - focusRequest?: FocusRequest; - onRegisterContextMenu?: Dispatch>; -} - -const NOTE_RENDERERS: Record> = { - [MARKDOWN_NOTE_TYPE_ID]: RenderedEditor, - [CANVAS_NOTE_TYPE_ID]: CanvasView, - [BOARD_NOTE_TYPE_ID]: BoardView, - [TABLE_NOTE_TYPE_ID]: TableGrid, - [MERMAID_NOTE_TYPE_ID]: MermaidView, - [CALENDAR_NOTE_TYPE_ID]: CalendarView, - [GRID_NOTE_TYPE_ID]: GridView, -}; - -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. - */ -export function MarkdownEditor({ - mode, - path, - value, - onChange, - disableToolbarInEdit = false, - viewState, - onViewStateChange, - syncSplitScroll = true, - isStandalone = false, - isReadOnly, - setNoteViewCtxBuilder, -}: MarkdownEditorProps) { - const { dispatch } = useWorkspace(); - const { settings } = useAppServices(); - const { notify } = useToasts(); - - const sourceCursorRef = useRef( - viewState?.sourceCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.sourceCursor, - ); - const renderedCursorRef = useRef( - viewState?.renderedCursor ?? DEFAULT_MARKDOWN_VIEW_STATE.renderedCursor, - ); - const activePaneRef = useRef( - viewState?.lastFocusedPane ?? DEFAULT_MARKDOWN_VIEW_STATE.lastFocusedPane, - ); - const tokenRef = useRef(3); - const syncLockRef = useRef(null); - 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; - }; - - const emitViewState = useCallback( - (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"; - } - return activePaneRef.current; - }, []); - - useEffect(() => { - requestPaneFocus(preferredPaneForMode(mode)); - }, [mode, preferredPaneForMode, requestPaneFocus]); - - useEffect(() => { - const prev = prevModeRef.current; - if (prev === mode) { - return; - } - - if (mode === "edit") { - const position = - activePaneRef.current === "rendered" ? renderedCursorRef.current : sourceCursorRef.current; - setSourceCursorRequest({ token: nextToken(), position }); - requestPaneFocus("source"); - } else if (mode === "rendered") { - const position = - activePaneRef.current === "source" ? sourceCursorRef.current : renderedCursorRef.current; - setRenderedCursorRequest({ token: nextToken(), position }); - requestPaneFocus("rendered"); - } else { - requestPaneFocus(preferredPaneForMode(mode)); - } - - prevModeRef.current = mode; - }, [mode, preferredPaneForMode, requestPaneFocus]); - - const showSource = mode === "edit" || mode === "split"; - const showRendered = mode === "rendered" || mode === "split"; - - 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) => { - sourceCursorRef.current = position; - emitViewState({ sourceCursor: position }); - }, - [emitViewState], - ); - - const handleRenderedCursorChange = useCallback( - (position: number) => { - renderedCursorRef.current = position; - emitViewState({ renderedCursor: position }); - }, - [emitViewState], - ); - - const handleSourceScrollChange = useCallback( - (ratio: number) => { - emitViewState({ sourceScrollRatio: ratio }); - if (!syncSplitScroll || mode !== "split") { - return; - } - if (syncLockRef.current === "source") { - syncLockRef.current = null; - return; - } - syncLockRef.current = "rendered"; - setRenderedScrollRequest({ token: nextToken(), ratio }); - }, - [emitViewState, mode, syncSplitScroll], - ); - - const handleRenderedScrollChange = useCallback( - (ratio: number) => { - emitViewState({ renderedScrollRatio: ratio }); - if (!syncSplitScroll || mode !== "split") { - return; - } - if (syncLockRef.current === "rendered") { - syncLockRef.current = null; - return; - } - syncLockRef.current = "source"; - setSourceScrollRequest({ token: nextToken(), ratio }); - }, - [emitViewState, mode, syncSplitScroll], - ); - - 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 NoteRenderer = NOTE_RENDERERS[frontType]; - - return ( -
- {mode === "edit" && disableToolbarInEdit && } -
- {showSource && ( -
- {} : onChange} - callbacks={callbacks} - focusRequest={sourceFocusRequest} - onFocus={handleSourceFocus} - scrollRequest={sourceScrollRequest} - onScrollChange={handleSourceScrollChange} - cursorRequest={sourceCursorRequest} - onCursorChange={handleSourceCursorChange} - /> -
- )} - {showRendered && ( - - )} -
-
- ); -} 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/native-source-editor.tsx b/packages/editor/src/native-source-editor.tsx index 861b37c..85a1278 100644 --- a/packages/editor/src/native-source-editor.tsx +++ b/packages/editor/src/native-source-editor.tsx @@ -7,38 +7,22 @@ useEffect, useRef, } from "react"; -import { - type CursorRequest, - droppedPathInsertion, - type EditorCallbacks, - type FocusRequest, - NOTES_PATH_MIME, - type ScrollRequest, -} from "./types"; +import { droppedPathInsertion, NOTES_PATH_MIME, RendererProps } from "./types"; +import { useSourcePaneSync } from "./pane-sync-context"; -interface NativeSourceEditorProps { - value: string; - onChange: (markdown: string) => void; - 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 -} +export function NativeSourceEditor({ value, onChange }: RendererProps) { + const { + callbacks, + scrollRequest, + onScrollChange, + focusRequest, + onFocus, + cursorRequest, + onCursorChange, + isReadOnly, + } = useSourcePaneSync() ?? {}; + const effectiveOnChange = isReadOnly ? () => {} : onChange; -export function NativeSourceEditor({ - value, - onChange, - callbacks, - scrollRequest, - onScrollChange, - focusRequest, - onFocus, - cursorRequest, - onCursorChange, -}: NativeSourceEditorProps) { const viewRef = useRef(null); const suppressScrollRef = useRef(false); @@ -176,7 +160,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 new file mode 100644 index 0000000..ef21971 --- /dev/null +++ b/packages/editor/src/note-view-descriptor.ts @@ -0,0 +1,73 @@ +/** + * NoteTypeDescriptor — the complete description of a note type. + * + * 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 { NoteTypeDetector } from "@notes/core"; +import type { ComponentType, ReactNode } from "react"; +import type { NoteViewContextMenuBuilder, ContextMenuEntry } from "@notes/ui"; +import type { EditorMode, RendererProps } from "./types"; + +export type NoteViewComponent = ComponentType; + +/** 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. +export type { NoteViewContextMenuBuilder, ContextMenuEntry }; + +/** + * Complete descriptor for a note type (id, detect, and all view capabilities). + * + * 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 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; + /** Toolbar items contributed by this note type. */ + toolbarItems?: NoteTypeToolbarItem[]; + /** Builds note-type-specific context menu items on right-click. */ + contextMenuBuilder?: NoteViewContextMenuBuilder; +} + +// ── Accessor helpers ────────────────────────────────────────────────────────── + +export function getNoteViewComponent(d: NoteTypeDescriptor): NoteViewComponent | undefined { + return d.viewComponent; +} + +export function getNoteViewToolbarItems(d: NoteTypeDescriptor): NoteTypeToolbarItem[] { + return d.toolbarItems ?? []; +} + +export function getNoteContextMenuBuilder( + d: NoteTypeDescriptor, +): NoteViewContextMenuBuilder | undefined { + return d.contextMenuBuilder; +} 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..317075d 100644 --- a/packages/editor/src/rendered-editor.tsx +++ b/packages/editor/src/rendered-editor.tsx @@ -23,15 +23,10 @@ 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"; interface MarkdownStorage { @@ -86,34 +81,23 @@ interface SuggestState { top: number; } -interface RenderedEditorProps { - value: string; - onChange: (markdown: string) => void; - 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, - isStandalone, toolbarDisabled = false, - cursorRequest, - scrollRequest, - onCursorChange, - onScrollChange, - onFocus, - focusRequest, -}: RenderedEditorProps) { +}: Omit & { toolbarDisabled?: boolean; path?: string }) { + // Context wins over props; props are fallbacks for standalone usage. + 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 044f498..416ee09 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -1,9 +1,32 @@ import type { ReactNode } from "react"; +/** The three editor modes available for a note-type view. */ export type EditorMode = "edit" | "split" | "rendered"; 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; @@ -84,3 +107,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/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/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-boards/src/board-note-type.ts b/packages/note-boards/src/board-note-type.ts index 4f581d2..04b17f5 100644 --- a/packages/note-boards/src/board-note-type.ts +++ b/packages/note-boards/src/board-note-type.ts @@ -1,11 +1,16 @@ -import type { NoteTypeProvider } from "@notes/core"; +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: NoteTypeProvider = { +export const boardNoteType: NoteTypeDescriptor = { id: BOARD_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "board"; }, + supportedModes: ["rendered"], + sourceProtected: true, + supportsScrollSync: false, + viewComponent: BoardView, }; 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 98d43af..b661238 100644 --- a/packages/note-calendar/src/calendar-note-type.ts +++ b/packages/note-calendar/src/calendar-note-type.ts @@ -1,11 +1,16 @@ -import type { NoteTypeProvider } from "@notes/core"; +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: NoteTypeProvider = { +export const calendarNoteType: NoteTypeDescriptor = { id: CALENDAR_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "calendar"; }, + supportedModes: ["rendered"], + sourceProtected: true, + supportsScrollSync: false, + viewComponent: CalendarView, }; 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-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 })} />
diff --git a/packages/note-canvas/src/canvas-note-type.ts b/packages/note-canvas/src/canvas-note-type.ts index 313f8bb..69cbdd6 100644 --- a/packages/note-canvas/src/canvas-note-type.ts +++ b/packages/note-canvas/src/canvas-note-type.ts @@ -1,11 +1,16 @@ -import type { NoteTypeProvider } from "@notes/core"; +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: NoteTypeProvider = { +export const canvasNoteType: NoteTypeDescriptor = { id: CANVAS_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".canvas"); }, + supportedModes: ["rendered"], + sourceProtected: true, + supportsScrollSync: false, + viewComponent: CanvasView, }; 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 c0c8676..f746382 100644 --- a/packages/note-grid/src/grid-note-type.ts +++ b/packages/note-grid/src/grid-note-type.ts @@ -1,11 +1,16 @@ -import type { NoteTypeProvider } from "@notes/core"; +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: NoteTypeProvider = { +export const gridNoteType: NoteTypeDescriptor = { id: GRID_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "grid"; }, + supportedModes: ["rendered"], + sourceProtected: true, + supportsScrollSync: false, + viewComponent: GridView, }; 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 0708f18..c518497 100644 --- a/packages/note-mermaid/src/mermaid-note-type.ts +++ b/packages/note-mermaid/src/mermaid-note-type.ts @@ -1,11 +1,16 @@ -import type { NoteTypeProvider } from "@notes/core"; +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: NoteTypeProvider = { +export const mermaidNoteType: NoteTypeDescriptor = { id: MERMAID_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "mermaid"; }, + supportedModes: ["edit", "split", "rendered"], + sourceProtected: false, + supportsScrollSync: false, + viewComponent: MermaidView, }; 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-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 1399fde..aeb1a91 100644 --- a/packages/note-tables/src/table-note-type.ts +++ b/packages/note-tables/src/table-note-type.ts @@ -1,11 +1,16 @@ -import type { NoteTypeProvider } from "@notes/core"; +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: NoteTypeProvider = { +export const tableNoteType: NoteTypeDescriptor = { id: TABLE_NOTE_TYPE_ID, detect(file) { return file.path.toLowerCase().endsWith(".md") && file.frontmatterType === "table"; }, + supportedModes: ["rendered"], + sourceProtected: true, + supportsScrollSync: false, + viewComponent: TableGrid, }; diff --git a/packages/plugin-host/src/context.ts b/packages/plugin-host/src/context.ts index 230c6e7..e388566 100644 --- a/packages/plugin-host/src/context.ts +++ b/packages/plugin-host/src/context.ts @@ -1,3 +1,4 @@ +import type { NoteTypeDetector } from "@notes/core"; import type { PluginManifest } from "./manifest"; export type Disposer = () => void; @@ -90,6 +91,12 @@ export interface PluginContext { * Returns a disposer that unregisters the handler. */ registerFileHandler: (handler: FileTypeHandler) => Disposer; + /** + * Registers a note-type renderer with full UI view capabilities (component, + * toolbar items, context menu, mode restrictions, scroll sync, etc.). + * Returns a disposer that unregisters the view when the plugin is disabled. + */ + registerNoteType: (descriptor: NoteTypeDetector) => Disposer; readonly document: DocumentSignal; readonly settings: PluginSettings; } @@ -106,6 +113,7 @@ export interface PluginHost { addStatusBarItem: (item: StatusBarItem) => Disposer; setThemeToken: (name: string, value: string) => Disposer; registerFileHandler: (handler: FileTypeHandler) => Disposer; + registerNoteType: (descriptor: NoteTypeDetector) => 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 f41ee58..e385d66 100644 --- a/packages/plugin-host/src/plugin-manager.test.ts +++ b/packages/plugin-host/src/plugin-manager.test.ts @@ -32,6 +32,9 @@ function makeHost() { fileHandlers.push(handler); return () => fileHandlers.splice(fileHandlers.indexOf(handler), 1); }, + registerNoteType: () => { + return () => {}; + }, document: new Signal(null), storage: { getItem: (key) => store.get(key) ?? null, diff --git a/packages/plugin-host/src/plugin-manager.ts b/packages/plugin-host/src/plugin-manager.ts index 5e4c662..4d3355a 100644 --- a/packages/plugin-host/src/plugin-manager.ts +++ b/packages/plugin-host/src/plugin-manager.ts @@ -111,6 +111,11 @@ export class PluginManager { entry.disposers.push(disposer); return disposer; }, + registerNoteType: (descriptor) => { + const disposer = this.host.registerNoteType(descriptor); + entry.disposers.push(disposer); + return disposer; + }, document: this.host.document, settings, };