From a19c910a0c67eb0ed9f9a78d2cf06f12571de620 Mon Sep 17 00:00:00 2001 From: poanse <50020771+poanse@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:32:52 +0300 Subject: [PATCH] many changes, including fixes and focus implementation --- src/Context.svelte.ts | 174 ++++++++++++++++++++++++- src/Custom.js | 4 +- src/NodePositionsCalculator.ts | 9 +- src/ProjectData.svelte.ts | 67 +++++++++- src/TaskmapView.ts | 19 +-- src/components/AddTaskButton.svelte | 124 ++++++++---------- src/components/Button.svelte | 15 ++- src/components/Connection.svelte | 2 +- src/components/HideBranchButton.svelte | 64 +++++++++ src/components/Task.svelte | 14 +- src/components/TaskText.svelte | 17 ++- src/components/TaskmapContainer.svelte | 8 +- src/components/Toolbar.svelte | 33 +++-- src/main.ts | 3 +- src/types.ts | 6 + 15 files changed, 433 insertions(+), 126 deletions(-) create mode 100644 src/components/HideBranchButton.svelte diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index 46287bd..3b26b36 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -1,10 +1,19 @@ import TaskmapPlugin from "./main"; import type { ProjectData } from "./ProjectData.svelte.js"; -import { StatusCode, type TaskId } from "./types"; +import { StatusCode, type TaskData, type TaskId } from "./types"; import { Spring } from "svelte/motion"; -import type { App } from "obsidian"; +import { + type App, + MarkdownView, + Notice, + TFile, + type WorkspaceLeaf, +} from "obsidian"; import type { TaskmapView } from "./TaskmapView"; -import type { NodePositionsCalculator } from "./NodePositionsCalculator"; +import { + type NodePositionsCalculator, + RootTaskId, +} from "./NodePositionsCalculator"; export class Context { app: App; @@ -12,6 +21,7 @@ export class Context { nodePositionsCalculator: NodePositionsCalculator; pressedButtonCode = $state(-1); selectedTaskId = $state(-1); + focusedTaskId = $state(RootTaskId); toolbarStatus = $state(StatusCode.DRAFT); projectData: ProjectData; taskPositions: Array<{ @@ -51,10 +61,40 @@ export class Context { this.updateOnZoomCounter += 1; } + public isTaskHidden(taskId: TaskId): boolean { + const task = this.projectData.getTask(taskId); + const ancestors = this.projectData + .getAncestors(this.focusedTaskId) + .map((t) => t.taskId); + const descendants = this.projectData.getDescendants(this.focusedTaskId); + return ( + task.deleted || + !( + task.taskId == this.focusedTaskId || + ancestors.contains(task.taskId) || + descendants.contains(task.taskId) + ) + ); + } + + public changeFocusedTask(taskId: TaskId): void { + this.focusedTaskId = taskId; + this.updateTaskPositions(); + } + + public isAncestorOfHidden(taskId: TaskId): boolean { + return this.projectData + .getAncestors(this.focusedTaskId) + .map((t) => t.taskId) + .contains(taskId); + } + public updateTaskPositions() { const positions = this.nodePositionsCalculator.CalculatePositionsInGlobalFrame( - this.projectData.tasks.filter((t) => !t.deleted), + this.projectData.tasks.filter( + (t) => !this.isTaskHidden(t.taskId), + ), { x: 0, y: 0 }, ); this.taskPositions = this.taskPositions.filter( @@ -63,7 +103,7 @@ export class Context { this.taskPositions.forEach((taskPos) => { const newPos = positions.get(taskPos.taskId); if (newPos === undefined) { - throw new Error(); + return; } if (taskPos.tween === null) { taskPos.tween = new Spring(newPos, this.springOptions); @@ -132,11 +172,135 @@ export class Context { // change its status } + public hideTaskBranch(id: number) { + this.projectData.getTask(id).hidden = true; + } + + public unhideTaskBranch(id: number) { + this.projectData.getTask(id).hidden = false; + } + public getCurrentTaskPosition(taskId: number) { return this.taskPositions.find((t) => t.taskId === taskId)!.tween! .current; } + public isRemoveButtonEnabled() { + return this.selectedTaskId != RootTaskId; + } + + /** + * Creates note named after task name if not exists already. + * Changes task name to a link to the node. + * Opens the note on the right side. + * @param taskId + */ + public async createLinkedNote(taskId: TaskId) { + const filepath = this.filePathFromTask(taskId); + + try { + let abstractFile = this.app.vault.getAbstractFileByPath(filepath); + let tfile: TFile = + abstractFile instanceof TFile + ? abstractFile + : await this.app.vault.create( + filepath, + `Created by Taskmap.`, + ); + const task = this.projectData.getTask(taskId); + task.name = this.tasknameFromFilePath(filepath); + this.save(); + await this.openOrFocusNote(tfile); + } catch (error) { + new Notice( + "Error creating note. It might already exist or the name is invalid.", + ); + console.error(error); + } + } + + public filePathFromTask(taskId: TaskId) { + const task = this.projectData.getTask(taskId); + const taskName = task.name; + // Sanitize the name for Obsidian filenames + let sanitizedName = taskName.replace(/[\\/:*?"<>|]/g, "-"); + sanitizedName = this.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. + * 2. If an unpinned tab exists in another pane -> Open it there. + * 3. If everything else is pinned or only one pane exists -> Create a new split. + */ + public async openOrFocusNote(file: TFile) { + const { workspace } = this.app; + + // 1. Check if the file is already open in any leaf (pinned or not) + let existingLeaf: WorkspaceLeaf | null = null; + workspace.iterateAllLeaves((leaf) => { + if ( + leaf.view instanceof MarkdownView && + leaf.view.file?.path === file.path + ) { + existingLeaf = leaf; + } + }); + + if (existingLeaf) { + workspace.setActiveLeaf(existingLeaf, { focus: true }); + return; + } + + // 2. Look for a reusable (unpinned) leaf in the root split + const activeLeaf = workspace.getMostRecentLeaf(); + const rootLeaves: WorkspaceLeaf[] = []; + workspace.iterateRootLeaves((leaf) => { + rootLeaves.push(leaf); + }); + + const anotherLeaf = rootLeaves.reverse().find((leaf) => { + return leaf !== activeLeaf; + }); + const anotherUnpinnedLeaf = rootLeaves.reverse().find((leaf) => { + return leaf !== activeLeaf && !leaf.getViewState().pinned; + }); + + if (anotherUnpinnedLeaf) { + // Reuse the unpinned secondary pane + await anotherUnpinnedLeaf.openFile(file); + workspace.setActiveLeaf(anotherUnpinnedLeaf, { focus: true }); + } else if (anotherLeaf) { + workspace.setActiveLeaf(anotherLeaf, { focus: true }); + const newLeaf = workspace.getLeaf("tab"); + await newLeaf.openFile(file); + if (activeLeaf !== null) { + workspace.setActiveLeaf(activeLeaf); + } + } else { + // 3. If no reusable leaf exists (all are pinned or only one exists), create a split + // This will create a new vertical pane that is unpinned by default + const newLeaf = workspace.getLeaf("split", "vertical"); + await newLeaf.openFile(file); + } + } + + 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/Custom.js b/src/Custom.js index 8da860e..b9cc742 100644 --- a/src/Custom.js +++ b/src/Custom.js @@ -63,8 +63,8 @@ export function slideCustom( `padding-${secondary_properties[1]}: ${t * padding_end_value}px;` + `margin-${secondary_properties[0]}: ${t * margin_start_value}px;` + `margin-${secondary_properties[1]}: ${t * margin_end_value}px;` + - `border-${secondary_properties[0]}-width: ${t * border_width_start_value}px;` + - `border-${secondary_properties[1]}-width: ${t * border_width_end_value}px;` + + // `border-${secondary_properties[0]}-width: ${t * border_width_start_value}px;` + + // `border-${secondary_properties[1]}-width: ${t * border_width_end_value}px;` + `min-${primary_property}: 0` + (axis === "-y" ? `; top: ${parseFloat(style["top"]) + primary_property_value * (1 - t)}px` diff --git a/src/NodePositionsCalculator.ts b/src/NodePositionsCalculator.ts index 66ead31..a7aae65 100644 --- a/src/NodePositionsCalculator.ts +++ b/src/NodePositionsCalculator.ts @@ -28,8 +28,9 @@ export enum AlgorithmEnum { DoubleRow, } +export const ParentToChildHorizontalShift = 400; + export class NodePositionsCalculator { - public readonly ParentToChildHorizontalShift = 400; public readonly SiblingDelta = 90; public Algorithm: AlgorithmEnum = AlgorithmEnum.DefaultTree; public subtreeWidthByHalfPriority: Map = new Map< @@ -222,7 +223,7 @@ export class NodePositionsCalculator { finalChildShifts.set( t.taskId, V2.add( - { x: this.ParentToChildHorizontalShift, y: 0 }, + { x: ParentToChildHorizontalShift, y: 0 }, verticalComponent, ), ); @@ -281,7 +282,7 @@ export class NodePositionsCalculator { x: finalChildShifts.get(x.taskId)!.x + subtreeRelWidthAccumulator * - this.ParentToChildHorizontalShift, + ParentToChildHorizontalShift, y: yShiftByRowId.get(this.RowIndex(x))!, }); subtreeRelWidthAccumulator += @@ -330,7 +331,7 @@ export class NodePositionsCalculator { x: finalChildShifts.get(x.taskId)!.x + (this.xshift + this.xshift * t.length + tSum) * - this.ParentToChildHorizontalShift, + ParentToChildHorizontalShift, y: this.RowIndex(x) === 0 ? yShiftByRowId.get(this.RowIndex(x))! diff --git a/src/ProjectData.svelte.ts b/src/ProjectData.svelte.ts index 3455788..a089ea4 100644 --- a/src/ProjectData.svelte.ts +++ b/src/ProjectData.svelte.ts @@ -37,6 +37,7 @@ export class ProjectData { priority: 0, depth: 0, deleted: false, + hidden: false, }); this.curTaskId++; } @@ -49,6 +50,7 @@ export class ProjectData { status: StatusCode.DRAFT, name: "default", deleted: false, + hidden: false, priority: 0, // TODO: change to amount of siblings depth: this.getTask(parentId).depth + 1, }); @@ -58,10 +60,13 @@ export class ProjectData { public removeTaskSingle(id: number) { const task = this.getTask(id); + const parentTask = this.getTask(task.parentId); task.deleted = true; - this.getChildren(id).forEach( - (taskId) => (this.getTask(taskId).parentId = task.parentId), - ); + this.getChildren(id).forEach((taskId) => { + const t = this.getTask(taskId); + t.parentId = parentTask.taskId; + t.depth = parentTask.depth + 1; + }); } public removeTaskBranch(id: number) { @@ -84,6 +89,16 @@ export class ProjectData { return result; } + public getAncestors(taskId: number) { + const res: TaskData[] = []; + let task = this.getTask(taskId); + while (task.depth != 0) { + task = this.getTask(task.parentId); + res.push(task); + } + return res; + } + public getChildren(taskId: number, includeDeleted: boolean = false) { let res = this.tasks.filter((t) => t.parentId === taskId); if (!includeDeleted) { @@ -105,18 +120,60 @@ export class ProjectData { return this.getTask(taskId).deleted; } + public isBranchHidden(id: number) { + return this.getAncestors(id).some((t) => t.hidden); + } + + public toggleHidden(id: number) { + const task = this.getTask(id); + task.hidden = !task.hidden; + } + public getTaskStatus(taskId: number) { return this.getTask(taskId)!.status; } + public getTaskName(taskId: number) { return this.getTask(taskId)!.name; } + public setTaskStatus(taskId: number, status: StatusCode) { const task = this.getTask(taskId); - if (task) { - task.status = status; + task.status = status; + this.recalculateStatusRecursive(task.parentId); + } + + public recalculateStatusRecursive(taskId: TaskId) { + if (taskId == RootTaskId) { + return; + } + const task = this.getTask(taskId); + if (task.status == StatusCode.DRAFT) { + return; + } + task.status = this.calculateStatus(taskId); + this.recalculateStatusRecursive(task.parentId); + } + + public calculateStatus(taskId: TaskId) { + const children = this.getChildren(taskId).map((x) => this.getTask(x)); + const counts = [0, 0, 0, 0]; + children.forEach((t) => (counts[t.status] += 1)); + if (counts[StatusCode.DONE] == children.length) { + return StatusCode.DONE; + } else if (counts[StatusCode.DONE] > 0) { + return StatusCode.IN_PROGRESS; + } else if (counts[StatusCode.IN_PROGRESS] > 0) { + return StatusCode.IN_PROGRESS; + } else if (counts[StatusCode.DRAFT] == children.length) { + return StatusCode.DRAFT; + } else if (counts[StatusCode.READY] == children.length) { + return StatusCode.READY; + } else { + return StatusCode.READY; } } + public setTaskName(taskId: number, name: string) { const task = this.getTask(taskId); if (task) { diff --git a/src/TaskmapView.ts b/src/TaskmapView.ts index 08f1a5e..bc89264 100644 --- a/src/TaskmapView.ts +++ b/src/TaskmapView.ts @@ -1,11 +1,11 @@ import { debounce, TextFileView, TFile, WorkspaceLeaf } from "obsidian"; -import { mount } from "svelte"; +import { mount, unmount } from "svelte"; import { DEFAULT_DATA, ProjectData } from "./ProjectData.svelte"; import { Context } from "./Context.svelte.js"; import { NodePositionsCalculator } from "./NodePositionsCalculator"; import TaskmapContainer from "./components/TaskmapContainer.svelte"; -export const VIEW_TYPE_EXAMPLE = "example"; +export const VIEW_TYPE = "taskmap-view"; export class TaskmapView extends TextFileView { taskmapContainer: ReturnType | undefined; @@ -20,7 +20,7 @@ export class TaskmapView extends TextFileView { projectData: ProjectData; getViewType() { - return VIEW_TYPE_EXAMPLE; + return VIEW_TYPE; } async onLoadFile(file: TFile): Promise { @@ -75,6 +75,12 @@ export class TaskmapView extends TextFileView { clear(): void { this.debouncedSave.cancel(); this.setViewData(DEFAULT_DATA); + // 3. Proper unmounting in the clear cycle + if (this.taskmapContainer) { + unmount(this.taskmapContainer); + this.taskmapContainer = undefined; + } + this.contentEl.empty(); } async onUnloadFile(file: TFile): Promise { @@ -86,11 +92,6 @@ export class TaskmapView extends TextFileView { } async onClose() { - this.debouncedSave.cancel(); + this.clear(); } - - // onChange(value: string) { - // this.setViewData(value); - // this.debouncedSave(); - // } } diff --git a/src/components/AddTaskButton.svelte b/src/components/AddTaskButton.svelte index e936c42..2c255cc 100644 --- a/src/components/AddTaskButton.svelte +++ b/src/components/AddTaskButton.svelte @@ -1,104 +1,82 @@  -
entered = true} + onmouseleave={() => entered = false} + style=" left: {TASK_SIZE.width - 50/2}px; top: {TASK_SIZE.height/2 - 50/2}px; - width: 50px; - height: 50px; " > -
- -{#if entered} + {#if entered} - - + + -{/if} + {/if} + diff --git a/src/components/Button.svelte b/src/components/Button.svelte index 38f8e57..688f5fb 100644 --- a/src/components/Button.svelte +++ b/src/components/Button.svelte @@ -1,7 +1,7 @@  + + import { TASK_SIZE } from "../Constants"; + import { Context } from "../Context.svelte.js"; + import {ParentToChildHorizontalShift} from "../NodePositionsCalculator"; + import { Eye, EyeClosed } from 'lucide-svelte'; + + const { taskId, context }: { taskId: number, context: Context } = $props(); + + let taskData = $derived(context.projectData.getTask(taskId)); + let entered = $state(false); + + function hidePressed(event: MouseEvent) { + context.projectData.toggleHidden(taskId); + event.stopPropagation(); + } + + +{#if context.projectData.getChildren(taskId).length > 0} +
entered = true} + onmouseleave={() => entered = false} + style=" + left: {TASK_SIZE.width / 2 + ParentToChildHorizontalShift / 2 - 50/2}px; + top: {TASK_SIZE.height / 2 - 50 / 2}px; + " + > + {#if taskData.hidden} + + {:else if entered && !taskData.hidden} + + {/if} +
+{/if} + + diff --git a/src/components/Task.svelte b/src/components/Task.svelte index f41ef68..60f2c97 100644 --- a/src/components/Task.svelte +++ b/src/components/Task.svelte @@ -3,6 +3,7 @@ import {StatusCode} from "../types"; import TaskText from "./TaskText.svelte"; import AddTaskButton from "./AddTaskButton.svelte"; + import HideBranchButton from "./HideBranchButton.svelte"; const { taskId, @@ -16,7 +17,7 @@ let taskData = $derived(context.projectData.getTask(taskId)); // let coords = $derived(context.getTaskPosition(taskId)); - + let isUnselected = $derived(context.isAncestorOfHidden(taskId)); let isHovered = $state(false); // derived here is a must let isSelected = $derived(context.isSelected(taskId)); @@ -99,14 +100,13 @@ -{#if !context.projectData.isTaskDeleted(taskId)} +{#if !context.isTaskHidden(taskId) && !context.projectData.isBranchHidden(taskId)} {#key context.updateOnZoomCounter}
isHovered = true} onmouseleave={() => isHovered = false} onmousedown={(event: MouseEvent) => { @@ -135,6 +136,7 @@ {#if useNewTextElement} +
{/key} {/if} @@ -168,6 +171,7 @@ diff --git a/src/components/TaskText.svelte b/src/components/TaskText.svelte index cf62ff7..8963f9e 100644 --- a/src/components/TaskText.svelte +++ b/src/components/TaskText.svelte @@ -1,12 +1,13 @@  @@ -43,11 +45,14 @@ " > {#key context.updateOnZoomCounter} -
{/if} @@ -57,7 +62,7 @@ class="subtoolbar" transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }} style=" - top: {getTop() - 2 * BUTTON_SIZE - TOOLBAR_GAP - 2 * TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT}px; + top: {getTop() - 2 * BUTTON_SIZE - TOOLBAR_GAP - 2 * TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT - 2}px; left: {getLeft() - 2}px; " > @@ -73,15 +78,20 @@ class="subtoolbar" transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }} style=" - top: {getTop() - 4 * BUTTON_SIZE - 3*TOOLBAR_GAP - 2*TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT}px; + top: {getTop() - (isLeafTask ? 2 : 1) * 2 * BUTTON_SIZE - ((isLeafTask ? 2 : 0) + 1)*TOOLBAR_GAP - 2*TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT - 2}px; left: {getLeft() + 4 * (BUTTON_SIZE + TOOLBAR_GAP) - 2}px; " > {#key context.updateOnZoomCounter} -