Reveal selected tasks in editor

This commit is contained in:
Yuri Karamian
2026-07-20 13:09:35 +02:00
parent 4aa0b200b9
commit 9b51aef073
3 changed files with 179 additions and 12 deletions
+2 -2
View File
@@ -252,11 +252,11 @@
// Tasks view: the editor pane shows a placeholder until a task is opened from the list. // Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false); let taskNoteOpened = $state(false);
function handleNoteSelected(path: string, content: string) { function handleNoteSelected(path: string, content: string, task?: TaskItem) {
// Selecting a real vault note exits viewer mode // Selecting a real vault note exits viewer mode
$viewerNote = null; $viewerNote = null;
taskNoteOpened = true; taskNoteOpened = true;
editor?.loadNote(path, content); editor?.loadNote(path, content, task);
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
+169 -2
View File
@@ -41,7 +41,7 @@
import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, outlineWidth } from '$lib/stores/app'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, outlineWidth } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import type { VersionEntry, AiStreamEvent, NoteTitleEntry, TaskItem as TaskRecord } from '$lib/types';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets'; import { encryptSecretText, decryptSecretText, readSecretTitle } from '$lib/utils/secrets';
@@ -86,6 +86,13 @@
let titleWasStripped = false; let titleWasStripped = false;
let strippedTitle = ''; let strippedTitle = '';
let strippedHeadingPrefix = ''; let strippedHeadingPrefix = '';
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
let taskRevealElement: HTMLElement | null = null;
let taskRevealRequest = 0;
const taskRevealPluginKey = new PluginKey('taskReveal');
type TaskRevealTarget = { sourceOrdinal: number; richOrdinal: number | null };
const TASK_LINE_RE = /^\s*[-*]\s\[[ xX]\]\s+.+?\s*$/;
const RICH_TASK_LINE_RE = /^\s*-\s\[(?:x| )\]\s+.+?\s*$/;
let headingDropdown = $state(false); let headingDropdown = $state(false);
let colorDropdown = $state(false); let colorDropdown = $state(false);
@@ -102,6 +109,112 @@
}); });
} }
function clearTaskReveal() {
if (taskRevealTimer) clearTimeout(taskRevealTimer);
taskRevealTimer = null;
taskRevealElement?.classList.remove('task-target-source');
taskRevealElement = null;
if (editor && !editor.isDestroyed) {
editor.view.dispatch(editor.state.tr.setMeta(taskRevealPluginKey, DecorationSet.empty));
}
}
function resolveTaskTarget(task: TaskRecord, content: string): TaskRevealTarget | null {
const lines = content.split(/\r?\n/).map((line) => line.replace(/\r$/, ''));
const rawLine = task.raw_line.replace(/\r$/, '');
const exactLines = lines.flatMap((line, index) => line === rawLine ? [index] : []);
const line = lines[task.line] === rawLine
? task.line
: exactLines.length === 1 ? exactLines[0] : null;
if (line === null || !TASK_LINE_RE.test(lines[line])) return null;
let sourceOrdinal = -1;
let richOrdinal = -1;
for (let index = 0; index <= line; index++) {
if (TASK_LINE_RE.test(lines[index])) sourceOrdinal++;
if (RICH_TASK_LINE_RE.test(lines[index])) richOrdinal++;
}
return {
sourceOrdinal,
richOrdinal: RICH_TASK_LINE_RE.test(lines[line]) ? richOrdinal : null,
};
}
function sourceTaskRange(ordinal: number): { start: number; end: number } | null {
let offset = 0;
let current = -1;
for (const line of sourceContent.split('\n')) {
if (TASK_LINE_RE.test(line.replace(/\r$/, ''))) {
current++;
if (current === ordinal) return { start: offset, end: offset + line.length };
}
offset += line.length + 1;
}
return null;
}
function revealTaskTarget(target: TaskRevealTarget): boolean {
clearTaskReveal();
if ($sourceMode) {
if (!sourceElement) return false;
const range = sourceTaskRange(target.sourceOrdinal);
if (!range) return true;
sourceElement.classList.add('task-target-source');
sourceElement.setSelectionRange(range.start, range.end);
scrollSourceToOffset(range.start);
requestAnimationFrame(() => scrollSourceToOffset(range.start));
taskRevealElement = sourceElement;
taskRevealTimer = setTimeout(() => {
if (sourceElement?.selectionStart === range.start && sourceElement.selectionEnd === range.end) {
sourceElement.setSelectionRange(range.end, range.end);
}
sourceElement?.classList.remove('task-target-source');
taskRevealElement = null;
taskRevealTimer = null;
}, 1600);
return true;
}
if (!editor || target.richOrdinal === null) return !!editor;
let current = -1;
let targetPos: number | null = null;
editor.state.doc.descendants((node, pos) => {
if (targetPos !== null || node.type.name !== 'taskItem') return targetPos === null;
current++;
if (current === target.richOrdinal) targetPos = pos;
return targetPos === null;
});
if (targetPos === null) return true;
const node = editor.state.doc.nodeAt(targetPos);
if (!node) return true;
const decorations = DecorationSet.create(editor.state.doc, [
Decoration.node(targetPos, targetPos + node.nodeSize, { class: 'task-target-reveal' }),
]);
editor.view.dispatch(editor.state.tr.setMeta(taskRevealPluginKey, decorations));
const element = editor.view.nodeDOM(targetPos);
if (!(element instanceof HTMLElement)) return true;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
element.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
const decoratedEditor = editor;
taskRevealTimer = setTimeout(() => {
if (!decoratedEditor.isDestroyed) {
decoratedEditor.view.dispatch(decoratedEditor.state.tr.setMeta(taskRevealPluginKey, DecorationSet.empty));
}
taskRevealTimer = null;
}, 2200);
return true;
}
function scheduleTaskReveal(path: string, target: TaskRevealTarget | null, request: number, attempts = 0) {
if (!target) return;
tick().then(() => requestAnimationFrame(() => {
if (request !== taskRevealRequest || path !== loadedPath) return;
if (!revealTaskTarget(target) && attempts < 3) {
scheduleTaskReveal(path, target, request, attempts + 1);
}
}));
}
function handleSourceCtrlEnd(event: KeyboardEvent) { function handleSourceCtrlEnd(event: KeyboardEvent) {
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.key !== 'End') return false; if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.key !== 'End') return false;
event.preventDefault(); event.preventDefault();
@@ -1597,6 +1710,28 @@
}, },
}); });
const TaskRevealExtension = Extension.create({
name: 'taskReveal',
addProseMirrorPlugins() {
return [
new Plugin({
key: taskRevealPluginKey,
state: {
init() { return DecorationSet.empty; },
apply(tr, old) {
const meta = tr.getMeta(taskRevealPluginKey);
if (meta !== undefined) return meta;
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) { return this.getState(state); },
},
}),
];
},
});
// Color swatch decorations: render a small filled square before every hex/rgb/hsl color // Color swatch decorations: render a small filled square before every hex/rgb/hsl color
// literal (in normal text AND code blocks), VSCode-style. These are view-only widget // literal (in normal text AND code blocks), VSCode-style. These are view-only widget
// decorations, so they never touch the document/markdown - the note stores only the plain // decorations, so they never touch the document/markdown - the note stores only the plain
@@ -3065,7 +3200,10 @@
$editorDirty = false; $editorDirty = false;
} }
export function loadNote(path: string, content: string) { export function loadNote(path: string, content: string, taskTarget?: TaskRecord) {
clearTaskReveal();
const revealRequest = ++taskRevealRequest;
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
loadedPath = path; loadedPath = path;
lastSourceMode = $sourceMode; lastSourceMode = $sourceMode;
isLoadingNote = true; isLoadingNote = true;
@@ -3114,6 +3252,7 @@
isLoadingNote = false; isLoadingNote = false;
} }
if (!isMobile && showOutline) scheduleOutline(); if (!isMobile && showOutline) scheduleOutline();
scheduleTaskReveal(path, revealTarget, revealRequest);
} }
function stripTitleH1(md: string): string { function stripTitleH1(md: string): string {
@@ -4304,6 +4443,7 @@
MoveLineShortcuts, MoveLineShortcuts,
TabIndent, TabIndent,
NoteSearchExtension, NoteSearchExtension,
TaskRevealExtension,
ColorSwatch, ColorSwatch,
TaskMetaDim, TaskMetaDim,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
@@ -5655,6 +5795,7 @@
} }
onDestroy(() => { onDestroy(() => {
clearTaskReveal();
destroyEditor(); destroyEditor();
unlistenFileChange?.(); unlistenFileChange?.();
}); });
@@ -9411,6 +9552,32 @@
margin: 4px 0; margin: 4px 0;
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li.task-target-reveal) {
border-radius: 6px;
animation: task-target-flash 2.2s ease-out;
}
@keyframes task-target-flash {
0%, 62% {
background: color-mix(in srgb, var(--accent) 24%, var(--bg-editor));
}
100% {
background: transparent;
}
}
.source-editor.task-target-source::selection {
background: color-mix(in srgb, var(--accent) 36%, transparent);
color: inherit;
}
@media (prefers-reduced-motion: reduce) {
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li.task-target-reveal) {
animation: none;
background: color-mix(in srgb, var(--accent) 24%, var(--bg-editor));
}
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label) { :global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label) {
display: flex; display: flex;
align-items: center; align-items: center;
+3 -3
View File
@@ -46,8 +46,8 @@
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
import { isMobile, isAndroid } from '$lib/platform'; import { isMobile, isAndroid } from '$lib/platform';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void; onNoteCreated?: () => void;
@@ -409,7 +409,7 @@
$activeNote = content; $activeNote = content;
$activeNotePath = task.note_path; $activeNotePath = task.note_path;
$editorDirty = false; $editorDirty = false;
onNoteSelected(task.note_path, content.content); onNoteSelected(task.note_path, content.content, task);
} catch (e) { } catch (e) {
console.error('Failed to open task note:', e); console.error('Failed to open task note:', e);
} }