Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions apps/web/src/components/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export function NoteEditor({
const [splitScrollSync, setSplitScrollSync] = useState(initialSession.viewState.splitScrollSync);
const ctxMenu = useContextMenu<HTMLElement | null>();
const [customCtxBuilder, setCustomCtxBuilder] = useState<CustomContextMenuBuilder | null>(null);
const [sourceCtxBuilder, setSourceCtxBuilder] = useState<CustomContextMenuBuilder | null>(null);
const regionRef = useRef<HTMLDivElement>(null);
const dirtyRef = useRef(false);
const contentRef = useRef("");
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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[] = [];
Expand All @@ -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 <div className="note-loading">Loading…</div>;
Expand All @@ -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);
}}
Expand Down Expand Up @@ -441,7 +455,12 @@ export function NoteEditor({
<div className={`markdown-editor markdown-editor--${effectiveMode}`}>
{showSource && (
<div className="editor-column editor-column--split">
<NativeSourceEditor value={content} onChange={handleChange} path={path} />
<NativeSourceEditor
value={content}
onChange={handleChange}
path={path}
onRegisterContextMenu={registerSourceCtxBuilder}
/>
</div>
)}
{showRendered && noteRenderer && (
Expand Down
54 changes: 50 additions & 4 deletions apps/web/src/state/use-editor-callbacks.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<EditorCallbacks>(
const callbacks = useMemo<EditorCallbacks>(
() => ({
onOpenWikilink: (name) => {
void (async () => {
Expand Down Expand Up @@ -63,7 +73,43 @@ export function useEditorCallbacks(isStandalone: boolean): EditorCallbacks {
},
renderEmbed: (embedTarget) => <EmbedWidget target={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 };
}
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 62 additions & 15 deletions packages/editor/src/native-source-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEditorCallbacks } from "@notes/web/src/state/use-editor-callbacks";
import type { ContextMenuEntry } from "@notes/ui";
import {
ClipboardEventHandler,
DragEventHandler,
Expand All @@ -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,
Expand All @@ -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<HTMLTextAreaElement | null>(null);
Expand Down Expand Up @@ -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 (
<textarea
ref={viewRef}
className="source-editor"
spellCheck="false"
value={value}
onChange={(event) => effectiveOnChange(event.target.value)}
onDragOver={handleDragover}
onDrop={handleDrop}
onPaste={handlePaste}
onFocus={onFocus}
onSelect={handleSelectionChange}
onScroll={handleScroll}
/>
<>
<textarea
ref={viewRef}
className="source-editor"
spellCheck="false"
value={value}
onChange={(event) => effectiveOnChange(event.target.value)}
onDragOver={handleDragover}
onDrop={handleDrop}
onPaste={handlePaste}
onFocus={onFocus}
onSelect={handleSelectionChange}
onScroll={handleScroll}
/>
{promptDialog}
</>
);
}
44 changes: 43 additions & 1 deletion packages/editor/src/rendered-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FindBar } from "@notes/web/src/components/find-bar";
import { buildContent, parseFrontmatter } from "@notes/web/src/lib/frontmatter";
import { useAppServices } from "@notes/web/src/state/app-services";
import { useEditorCallbacks } from "@notes/web/src/state/use-editor-callbacks";
import type { ContextMenuEntry } from "@notes/ui";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Table } from "@tiptap/extension-table";
Expand Down Expand Up @@ -87,6 +88,7 @@ export function RenderedEditor({
value,
onChange,
toolbarDisabled = false,
onRegisterContextMenu,
}: Omit<RendererProps, "path"> & { toolbarDisabled?: boolean; path?: string }) {
// Context wins over props; props are fallbacks for standalone usage.
const {
Expand All @@ -98,7 +100,7 @@ export function RenderedEditor({
onFocus,
focusRequest,
} = useRenderedPaneSync() ?? {};
const callbacks = useEditorCallbacks(isStandalone);
const { callbacks, promptDialog } = useEditorCallbacks(isStandalone);
const { settings } = useAppServices();
const [findOpen, setFindOpen] = useState(false);
const currentParts = parseFrontmatter(value);
Expand Down Expand Up @@ -442,6 +444,45 @@ export function RenderedEditor({
editor.commands.focus();
}, [editor, focusRequest, focusRequest?.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 rendered pane.
useEffect(() => {
if (!onRegisterContextMenu) return;
onRegisterContextMenu((target) => {
if (!editor) return null;
const { selection } = editor.state;
if (selection.empty) return null;
const selectedText = editor.state.doc.textBetween(selection.from, selection.to, "\n");
if (!selectedText.trim()) return null;
// Only activate for right-clicks inside the rendered column.
if (target && !target.closest(".editor-column--rendered")) return null;
const { from, to } = selection;
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) {
const noteName = notePath.replace(/\.md$/i, "").split("/").pop() ?? notePath;
editor.chain().focus().insertContentAt({ from, to }, `[[${noteName}]]`).run();
}
})();
},
},
];
return items;
});
return () => onRegisterContextMenu(null);
}, [onRegisterContextMenu, editor, callbacks]);

const handleDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!editor) {
Expand Down Expand Up @@ -625,6 +666,7 @@ export function RenderedEditor({
/>
)}
</div>
{promptDialog}
</div>
);
}
9 changes: 9 additions & 0 deletions packages/editor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ export interface EditorCallbacks {
* Use for standalone files that should not import local assets.
*/
disableFileDrop?: boolean;
/**
* Prompts the user to name a new note, creates it with the given content,
* and opens it in a tab.
*
* Returns the new note's path on success, or null if the user cancelled or
* an error occurred. When `mode` is `"move"` the caller is responsible for
* removing the selected content and inserting a wikilink after this resolves.
*/
extractToNewNote?: (content: string, mode: "copy" | "move") => Promise<string | null>;
}

// ── Renderer contract ─────────────────────────────────────────────────────────
Expand Down