From cd5a6f5d5ebcd6bb88f1ce1a953aa8b4a257b3bc Mon Sep 17 00:00:00 2001 From: poanse <50020771+poanse@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:33:11 +0300 Subject: [PATCH] link update on rename and tooltips --- src/Constants.ts | 2 +- src/Context.svelte.ts | 31 ++--- src/LinkManager.ts | 48 ++++++++ src/ProjectData.svelte.ts | 11 +- src/SaveManager.ts | 25 ++++ src/TaskmapView.ts | 35 +++--- src/Tooltip.ts | 56 +++++++++ src/components/Button.svelte | 2 + src/components/Task.svelte | 11 +- src/components/TaskText.svelte | 127 +++++++++----------- src/components/TaskmapContainer.svelte | 6 +- src/components/Toolbar.svelte | 157 ++++++++++++++----------- src/main.ts | 123 +++++++++++++++++-- src/types.ts | 1 + 14 files changed, 433 insertions(+), 202 deletions(-) create mode 100644 src/LinkManager.ts create mode 100644 src/SaveManager.ts create mode 100644 src/Tooltip.ts diff --git a/src/Constants.ts b/src/Constants.ts index 50e19f2..54437be 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -2,7 +2,7 @@ export const TOOLBAR_GAP = 2; export const TOOLBAR_PADDING = { x: 4, y: 4 }; export const TOOLBAR_SHIFT = 4; -export const SUBTOOLBAR_SHIFT = 2; +export const SUBTOOLBAR_SHIFT = 4; export const TOOLBAR_SIZE = { width: 98 * 2, diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index 5ef50e1..d488816 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -17,12 +17,15 @@ import { V2, } from "./NodePositionsCalculator"; import { DraggingManager } from "./DraggingManager.svelte"; +import { delink, LinkManager, tasknameFromFilePath } from "./LinkManager"; export class Context { app: App; view: TaskmapView; nodePositionsCalculator: NodePositionsCalculator; + linkManager: LinkManager; pressedButtonCode = $state(-1); + editingTaskId = $state(NoTaskId); draggedTaskId = $state(NoTaskId); selectedTaskId = $state(NoTaskId); focusedTaskId = $state(RootTaskId); @@ -53,6 +56,7 @@ export class Context { this.nodePositionsCalculator = nodePositionsCalculator; this.app = app; this.projectData = projectData; + this.linkManager = new LinkManager(app); this.taskPositions = projectData.tasks .filter((t) => !t.deleted) @@ -273,11 +277,9 @@ export class Context { } public save() { - const x = TaskmapPlugin.getActiveView(); - if (x != null) { - x.debouncedSave(); - console.log("saved"); - } + const x = this.view; + x.debouncedSave(); + console.log("saved"); } public addTask(parentId: TaskId): void { @@ -352,7 +354,7 @@ export class Context { `Created by Taskmap.`, ); const task = this.projectData.getTask(taskId); - task.name = this.tasknameFromFilePath(filepath); + task.name = tasknameFromFilePath(filepath); this.save(); await this.openOrFocusNote(tfile); } catch (error) { @@ -368,17 +370,10 @@ export class Context { const taskName = task.name; // Sanitize the name for Obsidian filenames let sanitizedName = taskName.replace(/[\\/:*?"<>|]/g, "-"); - sanitizedName = this.delink(sanitizedName); + sanitizedName = delink(sanitizedName); return `${sanitizedName}.md`; } - public tasknameFromFilePath(path: string) { - if (path.endsWith(".md")) { - path = path.slice(0, path.length - 3); - } - return `[[${path}]]`; - } - /** * Opens a file with "Smart Focus": * 1. If file is already open anywhere -> Focus it. @@ -437,14 +432,6 @@ export class Context { } } - public isLink(s: string): boolean { - return s.startsWith("[[") && s.endsWith("]]"); - } - - public delink(s: string) { - return this.isLink(s) ? s.slice(2, s.length - 2) : s; - } - public serializeForDebugging() { return JSON.stringify({ pressedButtonIndex: this.pressedButtonCode, diff --git a/src/LinkManager.ts b/src/LinkManager.ts new file mode 100644 index 0000000..4b2c182 --- /dev/null +++ b/src/LinkManager.ts @@ -0,0 +1,48 @@ +import type { App, TFile } from "obsidian"; + +export function tasknameFromFilePath(path: string) { + if (path.endsWith(".md")) { + path = path.slice(0, path.length - 3); + } + return `[[${path}]]`; +} + +export function isLink(s: string): boolean { + return s.startsWith("[[") && s.endsWith("]]"); +} + +export function delink(s: string) { + return isLink(s) ? s.slice(2, s.length - 2) : s; +} + +// relativePath to TFile +export function getFromRelativePath(app: App, path: string) { + return app.vault.getFileByPath(path); +} +// TFile to wikilink +export function generateMarkdownLink(app: App, file: TFile) { + return app.fileManager.generateMarkdownLink(file, ""); +} + +export class LinkManager { + private app: App; + + public constructor(app: App) { + this.app = app; + } + + // wikilink to TFile + public getFromLink = (linkText: string) => { + return this.app.metadataCache.getFirstLinkpathDest( + delink(linkText), + "", + ); + }; + + public extractTextLink = (text: string) => { + if (!isLink(text)) { + throw new Error("Text is not a link"); + } + return delink(text); + }; +} diff --git a/src/ProjectData.svelte.ts b/src/ProjectData.svelte.ts index 089667e..14b4ad0 100644 --- a/src/ProjectData.svelte.ts +++ b/src/ProjectData.svelte.ts @@ -1,5 +1,6 @@ import { StatusCode, type TaskData, type TaskId } from "./types"; import { NoTaskId, RootTaskId } from "./NodePositionsCalculator"; +import { serializeProjectData } from "./SaveManager"; export class ProjectData { tasks = $state(new Array()); @@ -12,14 +13,6 @@ export class ProjectData { }); } - public serialize() { - return JSON.stringify( - { tasks: this.tasks, curTaskId: this.curTaskId }, - null, - 2, - ); - } - constructor(obj: { tasks: TaskData[]; curTaskId: number }) { this.tasks = obj.tasks; this.curTaskId = obj.curTaskId; @@ -229,4 +222,4 @@ export class ProjectData { } } -export const DEFAULT_DATA = ProjectData.getDefault().serialize(); +export const DEFAULT_DATA = serializeProjectData(ProjectData.getDefault()); diff --git a/src/SaveManager.ts b/src/SaveManager.ts new file mode 100644 index 0000000..62355a9 --- /dev/null +++ b/src/SaveManager.ts @@ -0,0 +1,25 @@ +import { ProjectData } from "./ProjectData.svelte"; +import type { App, TFile } from "obsidian"; + +export function serializeProjectData(projectData: ProjectData) { + return JSON.stringify( + { + tasks: projectData.tasks, + curTaskId: projectData.curTaskId, + }, + null, + 2, + ); +} + +export function deserializeProjectData(app: App, data: string) { + return new ProjectData(JSON.parse(data)); +} + +export async function updateFile( + app: App, + file: TFile, + projectData: ProjectData, +) { + await app.vault.modify(file!, serializeProjectData(projectData)); +} diff --git a/src/TaskmapView.ts b/src/TaskmapView.ts index ccb1501..93c23ea 100644 --- a/src/TaskmapView.ts +++ b/src/TaskmapView.ts @@ -4,8 +4,9 @@ import { DEFAULT_DATA, ProjectData } from "./ProjectData.svelte"; import { Context } from "./Context.svelte.js"; import { NodePositionsCalculator } from "./NodePositionsCalculator"; import TaskmapContainer from "./components/TaskmapContainer.svelte"; +import { deserializeProjectData, updateFile } from "./SaveManager"; -export const VIEW_TYPE = "taskmap-view"; +export const TASKMAP_VIEW_TYPE = "taskmap-view"; export class TaskmapView extends TextFileView { taskmapContainer: ReturnType | undefined; @@ -18,24 +19,33 @@ export class TaskmapView extends TextFileView { data: string = DEFAULT_DATA; projectData: ProjectData; + context: Context; getViewType() { - return VIEW_TYPE; + return TASKMAP_VIEW_TYPE; + } + + async refreshUi() { + const projectFile = this.file!; + this.clear(); + await this.onLoadFile(projectFile); } async onLoadFile(file: TFile): Promise { this.file = file; - this.setViewData(await this.app.vault.read(file)); - this.projectData = new ProjectData(JSON.parse(this.data)); + const data = await this.app.vault.read(file); + this.setViewData(data); + this.projectData = deserializeProjectData(this.app, this.data); + this.context = new Context( + this, + this.projectData, + this.app, + new NodePositionsCalculator(), + ); this.taskmapContainer = mount(TaskmapContainer, { target: this.contentEl, props: { - context: new Context( - this, - this.projectData, - this.app, - new NodePositionsCalculator(), - ), + context: this.context, }, }); } @@ -70,10 +80,7 @@ export class TaskmapView extends TextFileView { async save() { try { - await this.app.vault.modify( - this.file!, - this.projectData.serialize(), - ); + await updateFile(this.app, this.file!, this.projectData); } catch (err) { console.error("Save failed:", err); throw new Error(`Save failed. ${err.message}`); diff --git a/src/Tooltip.ts b/src/Tooltip.ts new file mode 100644 index 0000000..b046835 --- /dev/null +++ b/src/Tooltip.ts @@ -0,0 +1,56 @@ +import { setTooltip } from "obsidian"; +import { IconCode } from "./types"; + +/** + * Svelte Action to add an Obsidian-native tooltip to an element. + * + * @param node - The DOM element to attach the tooltip to. + * @param text - The text to display in the tooltip. + */ +export function tooltip(node: HTMLElement, text: string) { + setTooltip(node, text, { + placement: "top", + }); + + return { + update(newText: string) { + // Update the tooltip if the text prop changes + setTooltip(node, newText, { + placement: "top", + }); + }, + }; +} + +export function getTooltipText(code: IconCode) { + switch (code) { + case IconCode.FOCUS: + return "Focus"; + case IconCode.CREATE_LINKED_NOTE: + return "Add link"; + case IconCode.REPARENT: + return "Reparent"; + case IconCode.KEY: + return "Block another task"; + case IconCode.LOCK: + return "Add blocker task"; + case IconCode.REMOVE: + return "Remove"; + case IconCode.REMOVE_SINGLE: + return "Remove single task"; + case IconCode.REMOVE_MULTIPLE: + return "Remove task branch"; + case IconCode.STATUS: + return "Status"; + case IconCode.STATUS_DRAFT: + return "Draft"; + case IconCode.STATUS_READY: + return "Ready"; + case IconCode.STATUS_IN_PROGRESS: + return "In progress"; + case IconCode.STATUS_DONE: + return "Done"; + default: + return "placeholder tooltip"; + } +} diff --git a/src/components/Button.svelte b/src/components/Button.svelte index 1b1a135..7981476 100644 --- a/src/components/Button.svelte +++ b/src/components/Button.svelte @@ -11,6 +11,7 @@ Trash2, Unplug } from 'lucide-svelte'; + import {getTooltipText, tooltip} from "../Tooltip"; let { iconCode, @@ -73,6 +74,7 @@
isHovered = true} onmouseleave={() => isHovered = false} onpointerdown={(e: PointerEvent) => { - context.startTaskDragging(e, taskId); + if (context.editingTaskId === NoTaskId) { + context.startTaskDragging(e, taskId); + } e.stopPropagation(); }} onpointerup={onPointerUp} @@ -87,12 +90,6 @@ {isUnselected} {context} app={context.app} - content={taskData.name} - onSave={(newContent)=> { - taskData.name = newContent; - context.save(); - }} - file={context.view.getFile()} />
{#if !context.taskDraggingManager.isDragging} diff --git a/src/components/TaskText.svelte b/src/components/TaskText.svelte index 63a7c06..f6a1c6e 100644 --- a/src/components/TaskText.svelte +++ b/src/components/TaskText.svelte @@ -1,8 +1,11 @@  @@ -189,10 +174,10 @@ class:unselect={isUnselected} maxlength="28" bind:this={textEditEl} - bind:value={content} onblur={handleBlur} onkeydown={handleKeydown} - > + oninput={handleInput} + >{taskData.name} {:else}
- + {#if context.selectedTaskId !== -1} + {#key context.selectedTaskId} + + {/key} + {/if}
diff --git a/src/components/Toolbar.svelte b/src/components/Toolbar.svelte index d72a8b1..6fc22f5 100644 --- a/src/components/Toolbar.svelte +++ b/src/components/Toolbar.svelte @@ -11,14 +11,16 @@ import {quintOut} from 'svelte/easing'; import {slideCustom} from '../Custom'; import type {Context} from "../Context.svelte.js"; - import {IconCode, toIconCode} from "../types"; + import {IconCode, StatusCode, type TaskId, toIconCode} from "../types"; import {RootTaskId} from "../NodePositionsCalculator"; let { + taskId, context - }: {context: Context} = $props(); + }: {taskId: TaskId, + context: Context} = $props(); - let position = $derived(context.getCurrentTaskPosition(context.selectedTaskId)); + let position = $derived(context.getCurrentTaskPosition(taskId)); function getTop() { return position.y - TOOLBAR_SIZE.height - TOOLBAR_SHIFT; @@ -26,76 +28,97 @@ function getLeft() { return position.x + TASK_SIZE.width_hovered / 2; } + let isLeafTask = $derived(context.projectData.getChildren(taskId).length === 0); - let isLeafTask = $derived(context.selectedTaskId != -1 && context.projectData.getChildren(context.selectedTaskId).length === 0); + let toolbarButtons = $derived(taskId == RootTaskId ? [ + IconCode.CREATE_LINKED_NOTE, + IconCode.FOCUS, + IconCode.STATUS + + ] : [ + IconCode.CREATE_LINKED_NOTE, + IconCode.REMOVE, + IconCode.REPARENT, + IconCode.KEY, + IconCode.LOCK, + IconCode.FOCUS, + IconCode.STATUS + ]); + + let removeButtons = [ + IconCode.REMOVE_SINGLE, + IconCode.REMOVE_MULTIPLE + ]; + + let statusButtons = $derived((isLeafTask ? [ + StatusCode.DRAFT, + StatusCode.READY, + StatusCode.IN_PROGRESS, + StatusCode.DONE, + ]: [ + StatusCode.DRAFT, + context.projectData.calculateStatus(taskId) + ]).map(x => toIconCode(x))); + + let subtoolbarTopShift = (buttons: IconCode[]) => { + return - (buttons.length * BUTTON_SIZE + (buttons.length -1)*TOOLBAR_GAP + 2*TOOLBAR_PADDING.y + SUBTOOLBAR_SHIFT); + }; -{#if context.selectedTaskId !== -1 && !context.taskDraggingManager.isDragging} -
e.stopPropagation()} - onpointerdown={(e) => e.stopPropagation()} - onpointerup={(e) => e.stopPropagation()} - style=" - top: {getTop()}px; - left: {getLeft()}px; - " -> - {#key context.updateOnZoomCounter} -
{/if} -