From 800c81e598204e358cb2bbf657c251cf87536c7e Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Thu, 25 Jun 2026 12:57:51 +0200 Subject: [PATCH] Add ! menu on task lines for setting priority and due date --- src/lib/components/Editor.svelte | 161 +++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 1708051..ab9a2bf 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -1913,6 +1913,114 @@ }, }); + // ── Task metadata (! priority / due) ── + + const TASK_META_ITEMS = [ + { label: 'High priority', token: '!high', kind: 'prio', icon: '' }, + { label: 'Medium priority', token: '!med', kind: 'prio', icon: '' }, + { label: 'Low priority', token: '!low', kind: 'prio', icon: '' }, + { label: 'Due date', token: '', kind: 'due', icon: '' }, + ]; + let taskMetaMenu = $state<{ x: number; y: number; from: number; to: number; query: string } | null>(null); + let taskMetaSelectedIndex = $state(0); + let taskMetaTypedByUser = false; + let taskDuePicker = $state<{ x: number; y: number } | null>(null); + let taskDueInputEl = $state(null); + + let taskMetaFiltered = $derived.by(() => { + const q = (taskMetaMenu?.query || '').toLowerCase(); + if (!q) return TASK_META_ITEMS; + return TASK_META_ITEMS.filter((it) => it.label.toLowerCase().startsWith(q) || it.token.slice(1).startsWith(q)); + }); + + function closeTaskMetaMenu() { + taskMetaMenu = null; + taskMetaSelectedIndex = 0; + } + + function updateTaskMetaMenu() { + const wasTyped = taskMetaTypedByUser; + taskMetaTypedByUser = false; + if (!editor || taskDuePicker) return; + const rfrom = editor.state.selection.$from; + let inTask = false; + for (let d = rfrom.depth; d > 0; d--) { + if (rfrom.node(d).type.name === 'taskItem') { inTask = true; break; } + } + if (!inTask || rfrom.parent.type.name !== 'paragraph') { closeTaskMetaMenu(); return; } + const textBefore = rfrom.parent.textContent.slice(0, rfrom.parentOffset); + const match = textBefore.match(/(^|\s)!([a-z]*)$/i); + if (!match) { closeTaskMetaMenu(); return; } + if (!taskMetaMenu && !wasTyped) return; + const query = match[2]; + const offset = textBefore.length - match[0].length + match[1].length; + const from = rfrom.start() + offset; + const to = rfrom.pos; + const coords = editor.view.coordsAtPos(from); + let x = coords.left; + if (x + 200 > window.innerWidth) x = window.innerWidth - 210; + const visibleBottom = window.innerHeight - keyboardHeight; + const menuH = 180; + let y = coords.bottom + 4; + if (y + menuH > visibleBottom) y = coords.top - menuH - 4; + if (y < 4) y = 4; + taskMetaMenu = { x, y, from, to, query }; + if (taskMetaSelectedIndex >= taskMetaFiltered.length) taskMetaSelectedIndex = 0; + } + + function selectTaskMeta(index: number) { + const items = taskMetaFiltered; + if (index < 0 || index >= items.length || !taskMetaMenu || !editor) return; + const item = items[index]; + const { from, to, x, y } = taskMetaMenu; + taskMetaMenu = null; + taskMetaSelectedIndex = 0; + if (item.kind === 'due') { + editor.chain().focus().deleteRange({ from, to }).run(); + taskDuePicker = { x, y }; + tick().then(() => { taskDueInputEl?.focus(); taskDueInputEl?.showPicker?.(); }); + return; + } + editor.chain().focus().deleteRange({ from, to }).insertContent(item.token + ' ').run(); + } + + function applyTaskDue(value: string) { + taskDuePicker = null; + if (!editor) return; + if (value) editor.chain().focus().insertContent(`due:${value} `).run(); + else editor.commands.focus(); + } + + const TaskMetaMenu = Extension.create({ + name: 'taskMetaMenu', + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('taskMetaMenu'), + props: { + handleTextInput: (_view, _from, _to, text) => { + if (text === '!') taskMetaTypedByUser = true; + return false; + }, + handleKeyDown: (_view, event) => { + if (!taskMetaMenu) return false; + const n = Math.max(1, taskMetaFiltered.length); + if (event.key === 'ArrowDown') { event.preventDefault(); taskMetaSelectedIndex = (taskMetaSelectedIndex + 1) % n; return true; } + if (event.key === 'ArrowUp') { event.preventDefault(); taskMetaSelectedIndex = (taskMetaSelectedIndex - 1 + n) % n; return true; } + if (event.key === 'Enter' || event.key === 'Tab') { + if (taskMetaFiltered.length > 0) { event.preventDefault(); selectTaskMeta(taskMetaSelectedIndex); return true; } + closeTaskMetaMenu(); + return false; + } + if (event.key === 'Escape') { event.preventDefault(); closeTaskMetaMenu(); return true; } + return false; + }, + }, + }), + ]; + }, + }); + // ── Wiki-links ── let wikiLinkMenu = $state<{ x: number; y: number; query: string; from: number } | null>(null); @@ -3985,6 +4093,7 @@ HeadingShortcuts, WrapSelectedText, SlashCommands, + TaskMetaMenu, MoveLineShortcuts, TabIndent, NoteSearchExtension, @@ -4114,6 +4223,7 @@ // On mobile, only check menus when they're already open or user just typed trigger char if (!isMobile || slashMenu || slashTypedByUser) updateSlashMenu(); if (!isMobile || wikiLinkMenu || wikiLinkTypedByUser) updateWikiLinkMenu(); + if (!isMobile || taskMetaMenu || taskMetaTypedByUser) updateTaskMetaMenu(); // Detect when cursor leaves a wiki-link mark and trigger rename check if ($appConfig?.enable_wiki_links && editor) { const curMarks = editor.state.selection.$from.marks(); @@ -6967,6 +7077,45 @@ {/if} +{#if taskMetaMenu} + +
+ +
e.stopPropagation()}> + {#if taskMetaFiltered.length === 0} +
No match
+ {:else} + {#each taskMetaFiltered as item, i} + + {/each} + {/if} +
+
+{/if} + +{#if taskDuePicker} + +
{ taskDuePicker = null; editor?.commands.focus(); }}> + e.stopPropagation()} + onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)} + onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }} + /> +
+{/if} + {#if wikiLinkMenu}