mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Reveal selected tasks in editor
This commit is contained in:
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -3113,8 +3251,9 @@
|
|||||||
pendingContent = content;
|
pendingContent = content;
|
||||||
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 {
|
||||||
const title = $activeNote?.meta.title;
|
const title = $activeNote?.meta.title;
|
||||||
@@ -4302,9 +4441,10 @@
|
|||||||
SlashCommands,
|
SlashCommands,
|
||||||
TaskMetaMenu,
|
TaskMetaMenu,
|
||||||
MoveLineShortcuts,
|
MoveLineShortcuts,
|
||||||
TabIndent,
|
TabIndent,
|
||||||
NoteSearchExtension,
|
NoteSearchExtension,
|
||||||
ColorSwatch,
|
TaskRevealExtension,
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user