diff --git a/apps/web/src/components/note-editor.tsx b/apps/web/src/components/note-editor.tsx index 58d879d..0aae15b 100644 --- a/apps/web/src/components/note-editor.tsx +++ b/apps/web/src/components/note-editor.tsx @@ -135,6 +135,7 @@ export function NoteEditor({ const [splitScrollSync, setSplitScrollSync] = useState(initialSession.viewState.splitScrollSync); const ctxMenu = useContextMenu(); const [customCtxBuilder, setCustomCtxBuilder] = useState(null); + const [sourceCtxBuilder, setSourceCtxBuilder] = useState(null); const regionRef = useRef(null); const dirtyRef = useRef(false); const contentRef = useRef(""); @@ -154,6 +155,10 @@ export function NoteEditor({ setCustomCtxBuilder(() => builder); }, []); + const registerSourceCtxBuilder = useCallback((builder: CustomContextMenuBuilder | null) => { + setSourceCtxBuilder(() => builder); + }, []); + // Flush any unsaved edit when the component unmounts (tab switch / close). useEffect(() => { return () => { @@ -321,6 +326,19 @@ export function NoteEditor({ : null; const ctxBuilder = customCtxBuilder ?? descriptorCtxBuilder; + /** Try each builder in priority order; return the first non-null result. */ + const resolveCtxItems = useCallback( + (target: HTMLElement | null) => { + for (const builder of [ctxBuilder, sourceCtxBuilder]) { + if (!builder) continue; + const result = builder(target); + if (result !== null && result !== undefined) return result; + } + return null; + }, + [ctxBuilder, sourceCtxBuilder], + ); + const runEditCommand = useCallback( (command: string, target: HTMLElement | null) => { target?.focus(); @@ -333,10 +351,8 @@ export function NoteEditor({ const contextMenuItems = useMemo(() => { const target = ctxMenu.menu?.data ?? null; - if (ctxBuilder) { - const custom = ctxBuilder(target); - if (custom) return custom; - } + const custom = resolveCtxItems(target); + if (custom) return custom; // Default: generic text-editing commands. const items: ContextMenuEntry[] = []; @@ -350,7 +366,7 @@ export function NoteEditor({ { label: "Select All", run: () => runEditCommand("selectAll", target) }, ); return items; - }, [ctxMenu.menu?.data, ctxBuilder, runEditCommand]); + }, [ctxMenu.menu?.data, resolveCtxItems, runEditCommand]); if (saveState === "loading") { return
Loading…
; @@ -366,11 +382,9 @@ export function NoteEditor({ if (!target || target.closest(".context-menu")) { return; } - if (ctxBuilder) { - const custom = ctxBuilder(target); - // If the note view's builder returns [] (empty), suppress the menu entirely. - if (custom !== undefined && custom !== null && custom.length === 0) return; - } + const custom = resolveCtxItems(target); + // If any builder returns [] (empty), suppress the menu entirely. + if (custom !== null && custom !== undefined && custom.length === 0) return; event.preventDefault(); ctxMenu.open({ x: event.clientX, y: event.clientY }, target); }} @@ -441,7 +455,12 @@ export function NoteEditor({
{showSource && (
- +
)} {showRendered && noteRenderer && ( diff --git a/apps/web/src/state/use-editor-callbacks.tsx b/apps/web/src/state/use-editor-callbacks.tsx index 8659861..84e62b3 100644 --- a/apps/web/src/state/use-editor-callbacks.tsx +++ b/apps/web/src/state/use-editor-callbacks.tsx @@ -1,5 +1,6 @@ import type { EditorCallbacks } from "@notes/editor"; -import { useMemo } from "react"; +import { usePromptDialog } from "@notes/editor"; +import { useMemo, type ReactNode } from "react"; import { api } from "../api/client"; import { EmbedWidget } from "../components/embed-widget"; import { @@ -16,18 +17,27 @@ function basename(path: string): string { return (path.split("/").pop() ?? path).replace(/\.[^.]+$/, ""); } +export interface EditorCallbacksResult { + callbacks: EditorCallbacks; + promptDialog: ReactNode; +} + /** * 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. + * + * Also returns a `promptDialog` ReactNode that must be rendered in the + * consuming component to support the `extractToNewNote` callback. */ -export function useEditorCallbacks(isStandalone: boolean): EditorCallbacks { +export function useEditorCallbacks(isStandalone: boolean): EditorCallbacksResult { const { dispatch } = useWorkspace(); const { settings } = useAppServices(); const { notify } = useToasts(); + const { openPrompt, promptDialog } = usePromptDialog(); - return useMemo( + const callbacks = useMemo( () => ({ onOpenWikilink: (name) => { void (async () => { @@ -63,7 +73,43 @@ export function useEditorCallbacks(isStandalone: boolean): EditorCallbacks { }, renderEmbed: (embedTarget) => , disableFileDrop: isStandalone, + extractToNewNote: isStandalone + ? undefined + : async (content, mode) => { + const result = await openPrompt({ + title: mode === "copy" ? "Copy to New Note" : "Move to New Note", + description: + mode === "copy" + ? "Create a new note containing the selected text." + : "Move the selected text to a new note and replace it with a wikilink.", + fields: [ + { + key: "name", + label: "Note name", + placeholder: "Enter note name", + required: true, + defaultValue: "", + }, + ], + confirmLabel: mode === "copy" ? "Copy" : "Move", + }); + if (!result) return null; + const name = result["name"].trim(); + if (!name) return null; + const path = name.toLowerCase().endsWith(".md") ? name : `${name}.md`; + const noteName = path.replace(/\.md$/i, "").split("/").pop() ?? name; + try { + await api.create(path, `# ${noteName}\n\n${content}\n`); + dispatch({ type: "openFile", path, title: noteName }); + return path; + } catch { + notify(`Couldn't create note "${noteName}"`, { kind: "error" }); + return null; + } + }, }), - [dispatch, notify, settings.mediaDirectory, isStandalone], + [dispatch, notify, settings.mediaDirectory, isStandalone, openPrompt], ); + + return { callbacks, promptDialog }; } diff --git a/package-lock.json b/package-lock.json index 12cd6a4..bd92106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4713,6 +4713,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" diff --git a/packages/editor/src/native-source-editor.tsx b/packages/editor/src/native-source-editor.tsx index e8ad07b..8268da1 100644 --- a/packages/editor/src/native-source-editor.tsx +++ b/packages/editor/src/native-source-editor.tsx @@ -1,4 +1,5 @@ import { useEditorCallbacks } from "@notes/web/src/state/use-editor-callbacks"; +import type { ContextMenuEntry } from "@notes/ui"; import { ClipboardEventHandler, DragEventHandler, @@ -11,7 +12,7 @@ import { import { droppedPathInsertion, NOTES_PATH_MIME, RendererProps } from "./types"; import { useSourcePaneSync } from "./pane-sync-context"; -export function NativeSourceEditor({ value, onChange }: RendererProps) { +export function NativeSourceEditor({ value, onChange, onRegisterContextMenu }: RendererProps) { const { scrollRequest, onScrollChange, @@ -22,7 +23,7 @@ export function NativeSourceEditor({ value, onChange }: RendererProps) { isReadOnly, isStandalone = false, } = useSourcePaneSync() ?? {}; - const callbacks = useEditorCallbacks(isStandalone); + const { callbacks, promptDialog } = useEditorCallbacks(isStandalone); const effectiveOnChange = isReadOnly ? () => {} : onChange; const viewRef = useRef(null); @@ -156,19 +157,65 @@ export function NativeSourceEditor({ value, onChange }: RendererProps) { view.setSelectionRange(anchor, anchor); }, [cursorRequest, cursorRequest?.token]); + // Register context menu builder so the parent NoteEditor can show + // "Copy to New Note" / "Move to New Note" when the user right-clicks a + // non-empty selection inside the source pane. + useEffect(() => { + if (!onRegisterContextMenu) return; + onRegisterContextMenu((target) => { + const textarea = viewRef.current; + if (!textarea) return null; + const { selectionStart, selectionEnd } = textarea; + if (selectionStart === selectionEnd) return null; + const selectedText = textarea.value.slice(selectionStart, selectionEnd); + if (!selectedText.trim()) return null; + // Only activate for right-clicks inside the source column. + if (target && !target.closest(".editor-column--split")) return null; + const start = selectionStart; + const end = selectionEnd; + const items: ContextMenuEntry[] = [ + { + label: "Copy to New Note", + run: () => { + void callbacks?.extractToNewNote?.(selectedText, "copy"); + }, + }, + { + label: "Move to New Note", + run: () => { + void (async () => { + const notePath = await callbacks?.extractToNewNote?.(selectedText, "move"); + if (notePath && viewRef.current) { + const noteName = notePath.replace(/\.md$/i, "").split("/").pop() ?? notePath; + viewRef.current.setRangeText(`[[${noteName}]]`, start, end, "end"); + const inputEvent = new Event("input", { bubbles: true }); + viewRef.current.dispatchEvent(inputEvent); + } + })(); + }, + }, + ]; + return items; + }); + return () => onRegisterContextMenu(null); + }, [onRegisterContextMenu, callbacks]); + return ( -