mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Add ! menu on task lines for setting priority and due date
This commit is contained in:
@@ -1913,6 +1913,114 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Task metadata (! priority / due) ──
|
||||||
|
|
||||||
|
const TASK_META_ITEMS = [
|
||||||
|
{ label: 'High priority', token: '!high', kind: 'prio', icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>' },
|
||||||
|
{ label: 'Medium priority', token: '!med', kind: 'prio', icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>' },
|
||||||
|
{ label: 'Low priority', token: '!low', kind: 'prio', icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>' },
|
||||||
|
{ label: 'Due date', token: '', kind: 'due', icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' },
|
||||||
|
];
|
||||||
|
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<HTMLInputElement | null>(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 ──
|
// ── Wiki-links ──
|
||||||
|
|
||||||
let wikiLinkMenu = $state<{ x: number; y: number; query: string; from: number } | null>(null);
|
let wikiLinkMenu = $state<{ x: number; y: number; query: string; from: number } | null>(null);
|
||||||
@@ -3985,6 +4093,7 @@
|
|||||||
HeadingShortcuts,
|
HeadingShortcuts,
|
||||||
WrapSelectedText,
|
WrapSelectedText,
|
||||||
SlashCommands,
|
SlashCommands,
|
||||||
|
TaskMetaMenu,
|
||||||
MoveLineShortcuts,
|
MoveLineShortcuts,
|
||||||
TabIndent,
|
TabIndent,
|
||||||
NoteSearchExtension,
|
NoteSearchExtension,
|
||||||
@@ -4114,6 +4223,7 @@
|
|||||||
// On mobile, only check menus when they're already open or user just typed trigger char
|
// On mobile, only check menus when they're already open or user just typed trigger char
|
||||||
if (!isMobile || slashMenu || slashTypedByUser) updateSlashMenu();
|
if (!isMobile || slashMenu || slashTypedByUser) updateSlashMenu();
|
||||||
if (!isMobile || wikiLinkMenu || wikiLinkTypedByUser) updateWikiLinkMenu();
|
if (!isMobile || wikiLinkMenu || wikiLinkTypedByUser) updateWikiLinkMenu();
|
||||||
|
if (!isMobile || taskMetaMenu || taskMetaTypedByUser) updateTaskMetaMenu();
|
||||||
// Detect when cursor leaves a wiki-link mark and trigger rename check
|
// Detect when cursor leaves a wiki-link mark and trigger rename check
|
||||||
if ($appConfig?.enable_wiki_links && editor) {
|
if ($appConfig?.enable_wiki_links && editor) {
|
||||||
const curMarks = editor.state.selection.$from.marks();
|
const curMarks = editor.state.selection.$from.marks();
|
||||||
@@ -6967,6 +7077,45 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if taskMetaMenu}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="slash-menu-overlay" onclick={closeTaskMetaMenu}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="slash-menu" style="left: {taskMetaMenu.x}px; top: {taskMetaMenu.y}px" onclick={(e) => e.stopPropagation()}>
|
||||||
|
{#if taskMetaFiltered.length === 0}
|
||||||
|
<div class="slash-menu-empty">No match</div>
|
||||||
|
{:else}
|
||||||
|
{#each taskMetaFiltered as item, i}
|
||||||
|
<button
|
||||||
|
class="slash-menu-item"
|
||||||
|
class:selected={i === taskMetaSelectedIndex}
|
||||||
|
onmouseenter={() => taskMetaSelectedIndex = i}
|
||||||
|
onmousedown={(e) => { e.preventDefault(); selectTaskMeta(i); }}
|
||||||
|
>
|
||||||
|
<span class="slash-menu-icon">{@html item.icon}</span>
|
||||||
|
<span class="slash-menu-label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if taskDuePicker}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="slash-menu-overlay" onclick={() => { taskDuePicker = null; editor?.commands.focus(); }}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="task-due-input"
|
||||||
|
bind:this={taskDueInputEl}
|
||||||
|
style="left: {taskDuePicker.x}px; top: {taskDuePicker.y}px"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onchange={(e) => applyTaskDue((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') { taskDuePicker = null; editor?.commands.focus(); } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if wikiLinkMenu}
|
{#if wikiLinkMenu}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}>
|
<div class="wiki-link-overlay" onclick={closeWikiLinkMenu}>
|
||||||
@@ -9970,6 +10119,18 @@
|
|||||||
z-index: 1500;
|
z-index: 1500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-due-input {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1501;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.slash-menu {
|
.slash-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
|||||||
Reference in New Issue
Block a user