From 7753caba8f1793c95032e9c83068c03dc82733e4 Mon Sep 17 00:00:00 2001 From: poanse <50020771+poanse@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:59:35 +0300 Subject: [PATCH] undo/redo on key press --- src/Context.svelte.ts | 182 +++++++--------- src/SaveManager.ts | 2 +- src/TaskmapView.ts | 6 +- src/components/AddTaskButton.svelte | 3 +- src/components/Button.svelte | 4 +- src/components/Connection.svelte | 2 +- src/components/HideBranchButton.svelte | 4 +- src/components/Task.svelte | 24 +-- src/components/TaskText.svelte | 21 +- src/components/TaskmapContainer.svelte | 37 +++- src/components/Toolbar.svelte | 16 +- src/data/Action.ts | 287 +++++++++++++++++++++++++ src/data/HistoryManager.ts | 39 ++++ src/{ => data}/ProjectData.svelte.ts | 84 +------- src/data/VersionedData.ts | 170 +++++++++++++++ src/main.ts | 2 +- src/types.ts | 4 +- 17 files changed, 662 insertions(+), 225 deletions(-) create mode 100644 src/data/Action.ts create mode 100644 src/data/HistoryManager.ts rename src/{ => data}/ProjectData.svelte.ts (70%) create mode 100644 src/data/VersionedData.ts diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index 7d833a1..c400ff7 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -1,5 +1,4 @@ import TaskmapPlugin from "./main"; -import type { ProjectData } from "./ProjectData.svelte.js"; import { type BlockerPair, MouseDown, @@ -24,6 +23,7 @@ import { } from "./NodePositionsCalculator"; import { DraggingManager } from "./DraggingManager.svelte"; import { delink, LinkManager, tasknameFromFilePath } from "./LinkManager"; +import { VersionedData } from "./data/VersionedData"; export class Context { app: App; @@ -40,7 +40,7 @@ export class Context { chosenBlockedId = $state(NoTaskId); toolbarStatus = $state(StatusCode.DRAFT); reparentingTaskId = $state(NoTaskId); - projectData: ProjectData; + versionedData: VersionedData; positions: Map; taskPositions: Array<{ taskId: TaskId; @@ -58,7 +58,7 @@ export class Context { constructor( plugin: TaskmapPlugin, view: TaskmapView, - projectData: ProjectData, + projectData: VersionedData, app: App, nodePositionsCalculator: NodePositionsCalculator, ) { @@ -66,54 +66,26 @@ export class Context { this.view = view; this.nodePositionsCalculator = nodePositionsCalculator; this.app = app; - this.projectData = projectData; + this.versionedData = projectData; this.linkManager = new LinkManager(app); - this.taskPositions = projectData.tasks - .filter((t) => !t.deleted) - .map((task) => { - return { - taskId: task.taskId, - tween: null, - }; - }); + this.taskPositions = projectData.getTasks().map((task) => { + return { + taskId: task.taskId, + tween: null, + }; + }); this.updateTaskPositions(); } - public isTaskBlocked(taskId: TaskId) { - if (this.projectData.getTask(taskId).status === StatusCode.DONE) { - return false; - } - return this.projectData.blockerPairs - .filter((p) => p.blocked === taskId) - .some( - (p) => - this.projectData.getTask(p.blocker).status !== - StatusCode.DONE, - ); - } - - public isTaskBlocking(taskId: TaskId) { - if (this.projectData.getTask(taskId).status === StatusCode.DONE) { - return false; - } - return this.projectData.blockerPairs - .filter((p) => p.blocker === taskId) - .some( - (p) => - this.projectData.getTask(p.blocked).status !== - StatusCode.DONE, - ); - } - public isBlockerHighlighted = (taskId: TaskId) => { - if (this.projectData.getTask(taskId).status === StatusCode.DONE) { + if (this.versionedData.getTask(taskId).status === StatusCode.DONE) { return false; } if (this.chosenBlockedId !== NoTaskId) { return ( this.chosenBlockedId === taskId || - this.projectData.containsBlockerPair({ + this.versionedData.containsBlockerPair({ blocked: this.chosenBlockedId, blocker: taskId, }) @@ -121,7 +93,7 @@ export class Context { } else if (this.chosenBlockerId !== NoTaskId) { return ( this.chosenBlockerId === taskId || - this.projectData.containsBlockerPair({ + this.versionedData.containsBlockerPair({ blocked: taskId, blocker: this.chosenBlockerId, }) @@ -152,32 +124,11 @@ export class Context { this.updateOnZoomCounter += 1; } - public isTaskHidden(taskId: TaskId): boolean { - const task = this.projectData.getTask(taskId); - const ancestorTasks = this.projectData.getAncestors(taskId); - - const focusedAncestorIds = this.projectData - .getAncestors(this.focusedTaskId) - .map((t) => t.taskId); - const focusedDescendantIds = this.projectData.getDescendantIds( - this.focusedTaskId, - ); - return ( - task.deleted || - ancestorTasks.some((t) => t.hidden) || - !( - task.taskId == this.focusedTaskId || - focusedAncestorIds.contains(task.taskId) || - focusedDescendantIds.contains(task.taskId) - ) - ); - } - public isValidBlockerTarget(taskId: TaskId) { if (taskId === NoTaskId || this.chosenBlockedId === NoTaskId) { return false; } - if (this.projectData.getTask(taskId).status == StatusCode.DONE) { + if (this.versionedData.getTask(taskId).status == StatusCode.DONE) { return false; } return this.isValidBlockerPairTarget({ @@ -190,7 +141,7 @@ export class Context { if (taskId === NoTaskId || this.chosenBlockerId === NoTaskId) { return false; } - if (this.projectData.getTask(taskId).status == StatusCode.DONE) { + if (this.versionedData.getTask(taskId).status == StatusCode.DONE) { return false; } return this.isValidBlockerPairTarget({ @@ -202,11 +153,11 @@ export class Context { private isValidBlockerPairTarget(blockerPair: BlockerPair) { return !( blockerPair.blocked === blockerPair.blocker || - this.projectData.isAncestorOf( + this.versionedData.isAncestorOf( blockerPair.blocked, blockerPair.blocker, ) || - this.projectData.isDescendentOf( + this.versionedData.isDescendentOf( blockerPair.blocked, blockerPair.blocker, ) @@ -225,16 +176,17 @@ export class Context { this.reparentingTaskId = NoTaskId; } - public isValidReparentingTarget(taskId: TaskId) { + public isValidReparentingTarget(candidate: TaskId) { if (this.reparentingTaskId == NoTaskId) { throw new Error("Incorrect state: reparentingTaskId expected"); } + const task = this.versionedData.getTask(this.reparentingTaskId); return ( - taskId != this.reparentingTaskId && - !this.projectData + candidate != this.reparentingTaskId && + candidate != task.parentId && + !this.versionedData .getDescendantIds(this.reparentingTaskId) - .contains(taskId) && - this.projectData.getTask(this.reparentingTaskId).parentId != taskId + .contains(candidate) ); } @@ -242,7 +194,7 @@ export class Context { if (this.reparentingTaskId == NoTaskId) { throw new Error("Incorrect state: reparentingTaskId expected"); } - this.projectData.changeParent(this.reparentingTaskId, newParentId); + this.versionedData.changeParent(this.reparentingTaskId, newParentId); this.updateTaskPositions(); } @@ -256,7 +208,7 @@ export class Context { } public isAncestorOfHidden(taskId: TaskId): boolean { - return this.projectData + return this.versionedData .getAncestors(this.focusedTaskId) .map((t) => t.taskId) .contains(taskId); @@ -266,15 +218,15 @@ export class Context { if (!draggingOnly) { const newPositions = this.nodePositionsCalculator.CalculatePositionsInGlobalFrame( - this.projectData.tasks.filter( - (t) => !this.isTaskHidden(t.taskId), - ), + this.versionedData + .getTasks() + .filter((t) => !this.isTaskHidden(t.taskId)), { x: 0, y: 0 }, ); if (this.taskDraggingManager.isDragging) { [ this.draggedTaskId, - ...this.projectData.getDescendantIds(this.draggedTaskId), + ...this.versionedData.getDescendantIds(this.draggedTaskId), ].forEach((t) => { const v = this.positions.get(t); if (v !== undefined) { @@ -283,8 +235,20 @@ export class Context { }); } this.positions = newPositions; + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const taskIdSet = new Set( + this.taskPositions.map((t) => t.taskId), + ); + this.positions.forEach((v, k) => { + if (k != NoTaskId && !taskIdSet.has(k)) { + this.taskPositions.push({ + taskId: k, + tween: null, + }); + } + }); this.taskPositions = this.taskPositions.filter( - (t) => !this.projectData.getTask(t.taskId).deleted, + (t) => !this.versionedData.getTaskOption(t.taskId)?.deleted, ); this.taskPositions.forEach((taskPos) => { const newPos = this.positions.get(taskPos.taskId); @@ -310,7 +274,7 @@ export class Context { }, 1 / this.scale, ); - const draggedTaskIds = this.projectData.getDescendantIds( + const draggedTaskIds = this.versionedData.getDescendantIds( this.draggedTaskId, ); this.taskPositions @@ -341,12 +305,12 @@ export class Context { private updateDraggedTaskPriority() { // Если порядок тасок по оси Y отличается от приоритетов, то меняем приоритеты и пересчитываем порядок - const draggedParentId = this.projectData.getTask( + const draggedParentId = this.versionedData.getTask( this.draggedTaskId, ).parentId; const orderedSiblings = this.taskPositions .filter((t) => - this.projectData + this.versionedData .getChildren(draggedParentId) .contains(t.taskId), ) @@ -355,11 +319,11 @@ export class Context { const newPriority = orderedSiblings.findIndex( (t) => t == this.draggedTaskId, ); - const oldPriority = this.projectData.getTask( + const oldPriority = this.versionedData.getTask( this.draggedTaskId, ).priority; if (newPriority != oldPriority) { - this.projectData.setPriority(this.draggedTaskId, newPriority); + this.versionedData.setPriority(this.draggedTaskId, newPriority); return true; } return false; @@ -372,7 +336,7 @@ export class Context { public setSelectedTaskId(taskId: number) { this.selectedTaskId = taskId; if (taskId != -1) { - this.toolbarStatus = this.projectData.getTaskStatus(taskId); + this.toolbarStatus = this.versionedData.getTask(taskId).status; } } @@ -383,26 +347,21 @@ export class Context { } public addTask(parentId: TaskId): void { - const id = this.projectData.addTask(parentId); + this.versionedData.addTask(parentId); this.save(); - this.taskPositions.push({ - taskId: id, - tween: null, - }); this.updateTaskPositions(); } public removeTaskSingle(id: number) { this.setSelectedTaskId(-1); - this.projectData.removeTaskSingle(id); - // this.taskPositions = this.taskPositions.filter((t) => t.taskId !== id); + this.versionedData.removeTaskSingle(id); this.updateTaskPositions(); this.save(); } public removeTaskBranch(id: number) { this.setSelectedTaskId(-1); - this.projectData.removeTaskBranch(id); + this.versionedData.removeTaskBranch(id); // this.taskPositions = this.taskPositions.filter((t) => t.taskId !== id); this.updateTaskPositions(); this.save(); @@ -412,24 +371,46 @@ export class Context { console.debug( `changeStatus from ${this.toolbarStatus} to ${status} for task ${this.selectedTaskId}`, ); - this.projectData.setTaskStatus(this.selectedTaskId, status); + this.versionedData.setStatus(this.selectedTaskId, status); this.toolbarStatus = status; this.view.debouncedSave(); } public hideTaskBranch(id: number) { - this.projectData.getTask(id).hidden = true; + this.versionedData.setHidden(id, true); } public unhideTaskBranch(id: number) { - this.projectData.getTask(id).hidden = false; + this.versionedData.setHidden(id, false); } public getCurrentTaskPosition(taskId: number) { + // TODO: ошибка при реверте удаления таски. Нет таски среди taskPositions. Вызов через Connection return this.taskPositions.find((t) => t.taskId === taskId)!.tween! .current; } + public isTaskHidden(taskId: TaskId): boolean { + const task = this.versionedData.getTask(taskId); + const ancestorTasks = this.versionedData.getAncestors(taskId); + + const focusedAncestorIds = this.versionedData + .getAncestors(this.focusedTaskId) + .map((t) => t.taskId); + const focusedDescendantIds = this.versionedData.getDescendantIds( + this.focusedTaskId, + ); + return ( + task.deleted || + ancestorTasks.some((t) => t.hidden) || + !( + task.taskId == this.focusedTaskId || + focusedAncestorIds.contains(task.taskId) || + focusedDescendantIds.contains(task.taskId) + ) + ); + } + /** * Creates note named after task name if not exists already. * Changes task name to a link to the node. @@ -448,8 +429,11 @@ export class Context { filepath, `Created by Taskmap.`, ); - const task = this.projectData.getTask(taskId); - task.name = tasknameFromFilePath(filepath); + this.versionedData.setName( + taskId, + tasknameFromFilePath(filepath), + filepath, + ); this.save(); await this.openOrFocusNote(tfile); } catch (error) { @@ -461,7 +445,7 @@ export class Context { } public filePathFromTask(taskId: TaskId) { - const task = this.projectData.getTask(taskId); + const task = this.versionedData.getTask(taskId); const taskName = task.name; // Sanitize the name for Obsidian filenames let sanitizedName = taskName.replace(/[\\/:*?"<>|]/g, "-"); @@ -532,7 +516,7 @@ export class Context { pressedButtonIndex: this.pressedButtonCode, selectedTaskId: this.selectedTaskId, toolbarStatus: this.toolbarStatus, - projectData: this.projectData, + projectData: this.versionedData, }); } } diff --git a/src/SaveManager.ts b/src/SaveManager.ts index 78fa274..04f4961 100644 --- a/src/SaveManager.ts +++ b/src/SaveManager.ts @@ -1,4 +1,4 @@ -import { ProjectData } from "./ProjectData.svelte"; +import { ProjectData } from "./data/ProjectData.svelte.js"; import type { App, TFile } from "obsidian"; export function serializeProjectData(projectData: ProjectData) { diff --git a/src/TaskmapView.ts b/src/TaskmapView.ts index 015cae0..0c9eeb2 100644 --- a/src/TaskmapView.ts +++ b/src/TaskmapView.ts @@ -1,11 +1,13 @@ import { debounce, TextFileView, TFile, WorkspaceLeaf } from "obsidian"; import { mount, unmount } from "svelte"; -import { DEFAULT_DATA, ProjectData } from "./ProjectData.svelte"; +import { DEFAULT_DATA, ProjectData } from "./data/ProjectData.svelte.js"; import { Context } from "./Context.svelte.js"; import { NodePositionsCalculator } from "./NodePositionsCalculator"; import TaskmapContainer from "./components/TaskmapContainer.svelte"; import { deserializeProjectData, updateFile } from "./SaveManager"; import type TaskmapPlugin from "./main"; +import { VersionedData } from "./data/VersionedData"; +import { HistoryManager } from "./data/HistoryManager"; export const TASKMAP_VIEW_TYPE = "taskmap-view"; @@ -41,7 +43,7 @@ export class TaskmapView extends TextFileView { this.context = new Context( this.plugin, this, - this.projectData, + new VersionedData(this.projectData, new HistoryManager()), this.app, new NodePositionsCalculator(), ); diff --git a/src/components/AddTaskButton.svelte b/src/components/AddTaskButton.svelte index 65ab13b..c23d76b 100644 --- a/src/components/AddTaskButton.svelte +++ b/src/components/AddTaskButton.svelte @@ -2,11 +2,10 @@ import { TASK_SIZE } from "../Constants"; import { StatusCode } from "../types"; import { Context } from "../Context.svelte.js"; - import {NoTaskId} from "../NodePositionsCalculator"; const { taskId, context }: { taskId: number, context: Context } = $props(); - let taskData = $derived(context.projectData.getTask(taskId)); + let taskData = $derived(context.versionedData.getTask(taskId)); let entered = $state(false); function addButtonPressed(event: PointerEvent) { diff --git a/src/components/Button.svelte b/src/components/Button.svelte index df5d745..7876988 100644 --- a/src/components/Button.svelte +++ b/src/components/Button.svelte @@ -21,7 +21,7 @@ }: { iconCode: IconCode, context: Context, - text: string + text?: string } = $props(); let isPressedDown = $state(false); @@ -49,7 +49,7 @@ (context.isReparentingOn() && [IconCode.LOCK, IconCode.KEY].contains(iconCode)) || (context.chosenBlockerId !== NoTaskId && [IconCode.REPARENT].contains(iconCode)) || (context.chosenBlockedId !== NoTaskId && [IconCode.REPARENT].contains(iconCode)) - || (iconCode === IconCode.STATUS_DONE && context.isTaskBlocked(context.selectedTaskId)) + || (iconCode === IconCode.STATUS_DONE && context.versionedData.isTaskBlocked(context.selectedTaskId)) ); function onpointerup(event: MouseEvent) { diff --git a/src/components/Connection.svelte b/src/components/Connection.svelte index faaf2a4..e62d79b 100644 --- a/src/components/Connection.svelte +++ b/src/components/Connection.svelte @@ -12,7 +12,7 @@ } = $props(); function isStartTaskDepthLE(start: TaskId, end: TaskId){ - return context.projectData.getTask(start).depth <= context.projectData.getTask(end).depth; + return context.versionedData.getTask(start).depth <= context.versionedData.getTask(end).depth; } function getConnectionPointShift(start: TaskId, end: TaskId) { diff --git a/src/components/HideBranchButton.svelte b/src/components/HideBranchButton.svelte index 61053ac..c19b551 100644 --- a/src/components/HideBranchButton.svelte +++ b/src/components/HideBranchButton.svelte @@ -6,11 +6,11 @@ const { taskId, context }: { taskId: number, context: Context } = $props(); - let taskData = $derived(context.projectData.getTask(taskId)); + let taskData = $derived(context.versionedData.getTask(taskId)); let entered = $state(false); function hidePressed(event: PointerEvent) { - context.projectData.toggleHidden(taskId); + context.versionedData.toggleHidden(taskId); event.stopPropagation(); context.finishTaskDragging(event, true); } diff --git a/src/components/Task.svelte b/src/components/Task.svelte index e71e249..32f1c60 100644 --- a/src/components/Task.svelte +++ b/src/components/Task.svelte @@ -17,7 +17,8 @@ coords: {x: number, y: number} } = $props(); - let taskData = $derived(context.projectData.getTask(taskId)); + + let taskData = $derived(context.versionedData.getTask(taskId)); // let coords = $derived(context.getTaskPosition(taskId)); let isHovered = $state(false); let isBlockerHighlighted = $derived( @@ -58,10 +59,10 @@ console.debug('Task click add blocker branch'); if ( context.isValidBlockerTarget(taskId)){ const blockerPair = {blocked: context.chosenBlockedId, blocker: taskId}; - if (context.projectData.containsBlockerPair(blockerPair)) { - context.projectData.removeBlockerPair(blockerPair); + if (context.versionedData.containsBlockerPair(blockerPair)) { + context.versionedData.removeBlockerPair(blockerPair); } else { - context.projectData.addBlockerPair(blockerPair); + context.versionedData.addBlockerPair(blockerPair); } context.save(); } @@ -70,10 +71,10 @@ console.debug('Task click add blocked branch'); if (context.isValidBlockedTarget(taskId)) { const blockerPair = {blocked: taskId, blocker: context.chosenBlockerId}; - if (context.projectData.containsBlockerPair(blockerPair)) { - context.projectData.removeBlockerPair(blockerPair); + if (context.versionedData.containsBlockerPair(blockerPair)) { + context.versionedData.removeBlockerPair(blockerPair); } else { - context.projectData.addBlockerPair(blockerPair); + context.versionedData.addBlockerPair(blockerPair); } context.save(); } @@ -104,7 +105,7 @@ ); -{#if !context.isTaskHidden(taskId) && !context.projectData.isBranchHidden(taskId)} +{#if !context.isTaskHidden(taskId) && !context.versionedData.isBranchHidden(taskId)} {#key context.updateOnZoomCounter}
{#if !context.taskDraggingManager.isDragging @@ -150,13 +150,13 @@ && !(context.chosenBlockerId !== NoTaskId)} {/if} - {#if (context.projectData.getChildren(taskId).length > 0) + {#if (context.versionedData.getChildren(taskId).length > 0) && !context.isReparentingOn() && !(context.chosenBlockedId !== NoTaskId) && !(context.chosenBlockerId !== NoTaskId)} {/if} - {#if context.isTaskBlocking(taskId)} + {#if context.versionedData.isTaskBlocking(taskId)}
import {onMount, tick} from "svelte"; - import {App, Component, MarkdownRenderer} from "obsidian"; + import {Component, MarkdownRenderer} from "obsidian"; import {LinkSuggest} from "../LinkSuggest"; import type {Context} from "../Context.svelte.js"; import {getFromRelativePath, isLink} from "../LinkManager"; - import type {TaskData} from "../types"; import {NoTaskId} from "../NodePositionsCalculator"; // PROPS @@ -12,16 +11,14 @@ taskId, isUnselected, context, - app, }: { taskId: number, isUnselected: boolean, context: Context, - app: App; } = $props(); // STATE - let taskData = context.projectData.getTask(taskId); + let taskData = context.versionedData.getTask(taskId); let isSelected = $derived(context.isSelected(taskId)); let isEditing = $derived(context.editingTaskId === taskId); let suggest: LinkSuggest | null = null; @@ -84,7 +81,7 @@ if (link) { // Trigger the native hover preview - app.workspace.trigger("hover-link", { + context.app.workspace.trigger("hover-link", { event: e, source: "preview", hoverParent: textPreviewEl, @@ -99,7 +96,7 @@ textPreviewEl.empty(); // Clear previous render const content = taskData.name; await MarkdownRenderer.render( - app, + context.app, content, textPreviewEl, "", @@ -120,7 +117,7 @@ await tick(); if (textEditEl) { textEditEl.focus(); - suggest = new LinkSuggest(app, textEditEl); + suggest = new LinkSuggest(context.app, textEditEl); } } @@ -147,21 +144,21 @@ if (textEditEl === null) { return; } - taskData.name = textEditEl.value; + let path; if (isLink(taskData.name)) { const file = context.linkManager.getFromLink(taskData.name); if (file === null) { throw new Error(`Link [${taskData.name}] points to a nonexistent file`); } - taskData.path = file.path; + path = file.path; } else { - taskData.path = undefined; + path = undefined; } + context.versionedData.setName(taskId, textEditEl.value, path); context.save(); } -
- import {onMount} from "svelte"; + import {onMount, tick} from "svelte"; import Toolbar from "./Toolbar.svelte"; import Task from "./Task.svelte"; import Panzoom, {type PanzoomObject} from '@panzoom/panzoom' @@ -19,7 +19,6 @@ let panstart: Vector2 = {x: 0, y: 0}; let draggingManager = new DraggingManager([MouseDown.MIDDLE, MouseDown.LEFT]); - onMount(async () => { if (!sceneEl) { throw new Error('No scene element'); @@ -42,15 +41,33 @@ }); }); - function handleKey(e: KeyboardEvent) { + async function handleKey(e: KeyboardEvent) { console.debug('handleKey ', e.key); if (e.key === "Escape") { - context.setSelectedTaskId(-1); + context.setSelectedTaskId(NoTaskId); context.cancelReparenting(); context.chosenBlockerId = NoTaskId; context.chosenBlockedId = NoTaskId; e.stopPropagation(); + return; + } else if (e.key === "Delete") { + if (context.selectedTaskId !== NoTaskId) { + context.versionedData.removeTaskSingle(context.selectedTaskId); + context.selectedTaskId = NoTaskId; + } + } else if (e.ctrlKey && e.key === "z") { + context.versionedData.undo(); + } else if (e.ctrlKey && e.key === "r") { + context.versionedData.redo(); + } else { + return; } + context.save(); + context.incrementUpdateOnZoomCounter();// required for TaskText update + context.updateTaskPositions(); + e.stopPropagation(); + e.preventDefault(); + await tick(); } function onwheel(e: WheelEvent) { @@ -157,10 +174,10 @@ - {#each (context.projectData.tasks + {#each (context.versionedData.getTasks() .filter(t => !context.isTaskHidden(t.taskId)) .filter(t => t.taskId !== RootTaskId) - .filter(t => !context.projectData.isBranchHidden(t.taskId)) + .filter(t => !context.versionedData.isBranchHidden(t.taskId)) ) as task (task.taskId)} - {#each context.projectData.tasks.filter(t => !context.isTaskHidden(t.taskId)) as task (task.taskId)} + {#each context.versionedData.getTasks().filter(t => !context.isTaskHidden(t.taskId)) as task (task.taskId)} {/each}
@@ -192,12 +209,12 @@ {#if context.chosenBlockerId !== NoTaskId || context.chosenBlockedId !== NoTaskId} - {#each (context.projectData.blockerPairs.filter( + {#each (context.versionedData.getBlockerPairs().filter( p => p.blocker === context.chosenBlockerId || p.blocked === context.chosenBlockedId ).filter( p => { - const blockedT = context.projectData.getTask(p.blocked); - const blockerT = context.projectData.getTask(p.blocker); + const blockedT = context.versionedData.getTask(p.blocked); + const blockerT = context.versionedData.getTask(p.blocker); return blockedT.status !== StatusCode.DONE && blockerT.status !== StatusCode.DONE && !blockerT.deleted && !blockedT.deleted; } )) as pair} diff --git a/src/components/Toolbar.svelte b/src/components/Toolbar.svelte index 6b42ebd..53c6567 100644 --- a/src/components/Toolbar.svelte +++ b/src/components/Toolbar.svelte @@ -15,6 +15,7 @@ IconCode, StatusCode, type TaskId, toIconCode } from "../types"; import {RootTaskId} from "../NodePositionsCalculator"; + import {onMount} from "svelte"; let { taskId, @@ -22,6 +23,16 @@ }: {taskId: TaskId, context: Context} = $props(); + let self: HTMLElement; + let viewport: HTMLElement; + + onMount(() => { + viewport = self?.closest('.viewport') as HTMLElement; + return () => { + viewport.focus(); + } + }); + let position = $derived(context.getCurrentTaskPosition(taskId)); function getTop() { @@ -30,7 +41,7 @@ function getLeft() { return position.x + TASK_SIZE.width_hovered / 2; } - let isLeafTask = $derived(context.projectData.getChildren(taskId).length === 0); + let isLeafTask = $derived(context.versionedData.getChildren(taskId).length === 0); let toolbarButtons = $derived(taskId == RootTaskId ? [ IconCode.CREATE_LINKED_NOTE, @@ -59,7 +70,7 @@ StatusCode.DONE, ]: [ StatusCode.DRAFT, - context.projectData.calculateStatus(taskId) + context.versionedData.calculateStatus(taskId) ]).map(x => toIconCode(x))); let subtoolbarTopShift = (buttons: IconCode[]) => { @@ -71,6 +82,7 @@
e.stopPropagation()} diff --git a/src/data/Action.ts b/src/data/Action.ts new file mode 100644 index 0000000..12396de --- /dev/null +++ b/src/data/Action.ts @@ -0,0 +1,287 @@ +import { type BlockerPair, StatusCode, type TaskId } from "../types"; +import { ProjectData } from "./ProjectData.svelte"; + +export interface Action { + do(data: ProjectData): void; + undo(data: ProjectData): void; +} + +export class AddTaskAction implements Action { + private parentId: TaskId; + private addedTaskId?: TaskId; + + constructor(parentId: TaskId) { + this.parentId = parentId; + } + + do(data: ProjectData): void { + const childrenCount = data.getChildren(this.parentId).length; + this.addedTaskId = data.curTaskId; + data.tasks.push({ + taskId: this.addedTaskId, + parentId: this.parentId, + status: StatusCode.READY, + name: "default", + deleted: false, + hidden: false, + priority: childrenCount, + depth: data.getTask(this.parentId).depth + 1, + }); + data.curTaskId++; + data.recalcStatusRecursive(this.parentId); + } + + undo(data: ProjectData): void { + if (this.addedTaskId === undefined) { + throw new Error(); + } + if (data.curTaskId != this.addedTaskId + 1) { + throw new Error(); + } + data.tasks = data.tasks.filter((t) => t.taskId !== this.addedTaskId); + data.curTaskId--; + this.addedTaskId = undefined; + } +} + +export class RemoveTaskSingleAction implements Action { + private taskId: TaskId; + private children?: TaskId[] = undefined; + + constructor(taskId: TaskId) { + this.taskId = taskId; + } + + do(data: ProjectData): void { + const task = data.getTask(this.taskId); + const parentTask = data.getTask(task.parentId); + task.deleted = true; + this.children = data.getChildren(this.taskId); + data.getChildren(task.parentId).forEach((taskId) => { + const t = data.getTask(taskId); + if (t.priority > task.priority) { + t.priority += this.children!.length; + } + }); + this.children.forEach((taskId) => { + const t = data.getTask(taskId); + t.priority += task.priority; + t.parentId = parentTask.taskId; + t.depth = parentTask.depth + 1; + }); + // TODO: Priorities suck + data.recalcPriorities(task.parentId); + data.recalcStatusRecursive(task.parentId); + } + + undo(data: ProjectData) { + if (this.children === undefined) { + throw new Error(); + } + const task = data.getTask(this.taskId); + task.deleted = false; + this.children.forEach((taskId) => { + const t = data.getTask(taskId); + t.parentId = task.taskId; + t.depth = task.depth + 1; + }); + // TODO: Priorities suck + data.recalcPriorities(task.parentId); + data.recalcPriorities(task.taskId); + data.recalcStatusRecursive(task.taskId); + this.children = undefined; + } +} + +export class RemoveTaskBranchAction implements Action { + private taskId: TaskId; + private descendants?: TaskId[] = undefined; + + constructor(taskId: TaskId) { + this.taskId = taskId; + } + + do(data: ProjectData): void { + // Memorize descendents that are being removed on this call, because other descendants that were removed earlier can exist + this.descendants = data.getDescendantIds(this.taskId); + this.toggleDeleted(this.descendants, true, data); + } + + undo(data: ProjectData) { + if (this.descendants === undefined) { + throw new Error(); + } + this.toggleDeleted(this.descendants, false, data); + this.descendants = undefined; + } + + private toggleDeleted( + descendants: TaskId[], + value: boolean, + data: ProjectData, + ) { + descendants.forEach((taskId) => (data.getTask(taskId).deleted = value)); + const parentId = data.getTask(this.taskId).parentId; + data.recalcPriorities(parentId); + data.recalcStatusRecursive(parentId); + } +} + +export class SetTaskStatusAction implements Action { + private taskId: TaskId; + private newStatus: StatusCode; + private oldStatus?: StatusCode; + + constructor(taskId: TaskId, newStatus: StatusCode) { + this.taskId = taskId; + this.newStatus = newStatus; + } + + do(data: ProjectData) { + this.oldStatus = data.getTask(this.taskId).status; + const task = data.getTask(this.taskId); + task.status = this.newStatus; + data.recalcStatusRecursive(task.parentId); + } + + undo(data: ProjectData) { + if (this.oldStatus === undefined) { + throw new Error(); + } + const task = data.getTask(this.taskId); + task.status = this.oldStatus; + data.recalcStatusRecursive(task.parentId); + this.oldStatus = undefined; + } +} + +export class SetTaskNameAction implements Action { + private taskId: TaskId; + private newName: string; + private newPath?: string; + private oldName?: string; + private oldPath?: string; + + constructor(taskId: TaskId, newName: string, path?: string) { + this.taskId = taskId; + this.newName = newName; + this.newPath = path; + } + + do(data: ProjectData): void { + this.oldName = data.getTask(this.taskId).name; + this.oldPath = data.getTask(this.taskId).path; + const task = data.getTask(this.taskId); + task.name = this.newName; + task.path = this.newPath; + } + + undo(data: ProjectData): void { + if (this.oldName === undefined) { + throw new Error(); + } + const task = data.getTask(this.taskId); + task.name = this.oldName; + task.path = this.oldPath; + this.oldName = undefined; + this.oldPath = undefined; + } +} + +export class SetTaskPriorityAction implements Action { + private taskId: TaskId; + private newPriority: number; + private oldPriority?: number; + + constructor(taskId: TaskId, newPriority: number) { + this.taskId = taskId; + this.newPriority = newPriority; + } + + do(data: ProjectData) { + this.oldPriority = data.getTask(this.taskId).priority; + data.setPriority(this.taskId, this.newPriority); + } + + undo(data: ProjectData) { + if (this.oldPriority === undefined) { + throw new Error(); + } + data.setPriority(this.taskId, this.oldPriority); + this.oldPriority = undefined; + } +} + +export class ChangeParentAction implements Action { + private taskId: TaskId; + private newParentId: TaskId; + private oldParentId?: TaskId; + + constructor(taskId: TaskId, newParentId: number) { + this.taskId = taskId; + this.newParentId = newParentId; + } + + do(data: ProjectData) { + this.oldParentId = data.getTask(this.taskId).parentId; + data.changeParent(this.taskId, this.newParentId); + } + + undo(data: ProjectData) { + if (this.oldParentId === undefined) { + throw new Error(); + } + data.changeParent(this.taskId, this.oldParentId); + this.oldParentId = undefined; + } +} + +export class SetTaskHiddenAction implements Action { + private taskId: TaskId; + private value: boolean; + + constructor(taskId: TaskId, value: boolean) { + this.taskId = taskId; + this.value = value; + } + + do(data: ProjectData): void { + data.getTask(this.taskId).hidden = this.value; + } + + undo(data: ProjectData): void { + data.getTask(this.taskId).hidden = !this.value; + } +} + +export class AddBlockerPairAction implements Action { + private blockerPair: BlockerPair; + + constructor(blockerPair: BlockerPair) { + this.blockerPair = blockerPair; + } + + do(data: ProjectData): void { + data.addBlockerPair(this.blockerPair); + } + + undo(data: ProjectData): void { + data.removeBlockerPair(this.blockerPair); + } +} + +export class RemoveBlockerPairAction implements Action { + private blockerPair: BlockerPair; + + constructor(blockerPair: BlockerPair) { + this.blockerPair = blockerPair; + } + + do(data: ProjectData): void { + data.removeBlockerPair(this.blockerPair); + } + + undo(data: ProjectData): void { + data.addBlockerPair(this.blockerPair); + } +} diff --git a/src/data/HistoryManager.ts b/src/data/HistoryManager.ts new file mode 100644 index 0000000..403bdba --- /dev/null +++ b/src/data/HistoryManager.ts @@ -0,0 +1,39 @@ +import { ProjectData } from "./ProjectData.svelte"; +import type { Action } from "./Action"; + +export class HistoryManager { + private undoStack: Action[] = []; + private redoStack: Action[] = []; + + execute(action: Action, data: ProjectData): void { + action.do(data); + this.undoStack.push(action); + this.redoStack = []; // Clear redo stack on new action + } + + undo(data: ProjectData): boolean { + const action = this.undoStack.pop(); + if (!action) return false; + + action.undo(data); + this.redoStack.push(action); + return true; + } + + redo(data: ProjectData): boolean { + const action = this.redoStack.pop(); + if (!action) return false; + + action.do(data); + this.undoStack.push(action); + return true; + } + + canUndo(): boolean { + return this.undoStack.length > 0; + } + + canRedo(): boolean { + return this.redoStack.length > 0; + } +} diff --git a/src/ProjectData.svelte.ts b/src/data/ProjectData.svelte.ts similarity index 70% rename from src/ProjectData.svelte.ts rename to src/data/ProjectData.svelte.ts index 230d627..5f73942 100644 --- a/src/ProjectData.svelte.ts +++ b/src/data/ProjectData.svelte.ts @@ -3,9 +3,9 @@ StatusCode, type TaskData, type TaskId, -} from "./types"; -import { NoTaskId, RootTaskId } from "./NodePositionsCalculator"; -import { serializeProjectData } from "./SaveManager"; +} from "../types"; +import { NoTaskId, RootTaskId } from "../NodePositionsCalculator"; +import { serializeProjectData } from "../SaveManager"; export class ProjectData { tasks = $state(new Array()); @@ -47,46 +47,6 @@ export class ProjectData { this.curTaskId++; } - public addTask(parentId: TaskId) { - const childrenCount = this.getChildren(parentId).length; - const id = this.curTaskId; - this.tasks.push({ - taskId: id, - parentId: parentId, - status: StatusCode.READY, - name: "default", - deleted: false, - hidden: false, - priority: childrenCount, - depth: this.getTask(parentId).depth + 1, - }); - this.curTaskId++; - this.recalculateStatusRecursive(parentId); - return id; - } - - public removeTaskSingle(id: number) { - const task = this.getTask(id); - const parentTask = this.getTask(task.parentId); - task.deleted = true; - this.getChildren(id).forEach((taskId) => { - const t = this.getTask(taskId); - t.parentId = parentTask.taskId; - t.depth = parentTask.depth + 1; - }); - this.recalcPriorities(task.parentId); - this.recalculateStatusRecursive(task.parentId); - } - - public removeTaskBranch(id: number) { - this.getDescendantIds(id).forEach( - (taskId) => (this.getTask(taskId).deleted = true), - ); - const parentId = this.getTask(id).parentId; - this.recalcPriorities(parentId); - this.recalculateStatusRecursive(parentId); - } - public getDescendantIds(taskId: number, includeDeleted: boolean = false) { const tasks = [taskId]; const result: TaskId[] = []; @@ -142,42 +102,19 @@ 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); - task.status = status; - this.recalculateStatusRecursive(task.parentId); - } - public changeParent(taskId: TaskId, newParentId: TaskId) { const taskData = this.getTask(taskId); const oldParentId = taskData.parentId; taskData.parentId = newParentId; - this.recalculateStatusRecursive(oldParentId); - this.recalculateStatusRecursive(newParentId); + this.recalcStatusRecursive(oldParentId); + this.recalcStatusRecursive(newParentId); [taskId, ...this.getDescendantIds(taskId)].forEach((taskId) => { const task = this.getTask(taskId); task.depth = this.getTask(task.parentId).depth + 1; }); } - public recalculateStatusRecursive(taskId: TaskId) { + public recalcStatusRecursive(taskId: TaskId) { if (taskId == RootTaskId) { return; } @@ -186,7 +123,7 @@ export class ProjectData { return; } task.status = this.calculateStatus(taskId); - this.recalculateStatusRecursive(task.parentId); + this.recalcStatusRecursive(task.parentId); } public calculateStatus(taskId: TaskId) { @@ -211,13 +148,6 @@ export class ProjectData { } } - public setTaskName(taskId: number, name: string) { - const task = this.getTask(taskId); - if (task) { - task.name = name; - } - } - public setPriority(taskId: TaskId, newPriority: number) { const task = this.getTask(taskId); const oldPriority = task.priority; diff --git a/src/data/VersionedData.ts b/src/data/VersionedData.ts new file mode 100644 index 0000000..ce99303 --- /dev/null +++ b/src/data/VersionedData.ts @@ -0,0 +1,170 @@ +import { HistoryManager } from "./HistoryManager"; +import { type BlockerPair, StatusCode, type TaskId } from "../types"; +import { ProjectData } from "./ProjectData.svelte"; +import { + AddTaskAction, + SetTaskHiddenAction, + RemoveTaskBranchAction, + RemoveTaskSingleAction, + SetTaskPriorityAction, + SetTaskStatusAction, + AddBlockerPairAction, + RemoveBlockerPairAction, + ChangeParentAction, + SetTaskNameAction, +} from "./Action"; + +export class VersionedData { + private data: ProjectData; + private history: HistoryManager; + + constructor(data: ProjectData, history: HistoryManager) { + this.data = data; + this.history = history; + } + + public undo = () => { + if (this.history.canUndo()) { + this.history.undo(this.data); + } + }; + + public redo = () => { + if (this.history.canRedo()) { + this.history.redo(this.data); + } + }; + + public addBlockerPair = (blockerPair: BlockerPair) => { + this.history.execute(new AddBlockerPairAction(blockerPair), this.data); + }; + + public removeBlockerPair = (blockerPair: BlockerPair) => { + this.history.execute( + new RemoveBlockerPairAction(blockerPair), + this.data, + ); + }; + + public getBlockerPairs = () => { + return this.data.blockerPairs; + }; + + public addTask = (parentId: TaskId) => { + this.history.execute(new AddTaskAction(parentId), this.data); + }; + + public removeTaskSingle = (taskId: TaskId) => { + this.history.execute(new RemoveTaskSingleAction(taskId), this.data); + }; + + public removeTaskBranch = (taskId: TaskId) => { + this.history.execute(new RemoveTaskBranchAction(taskId), this.data); + }; + + public setStatus = (taskId: TaskId, status: StatusCode) => { + this.history.execute( + new SetTaskStatusAction(taskId, status), + this.data, + ); + }; + + public setPriority = (taskId: TaskId, priority: number) => { + this.history.execute( + new SetTaskPriorityAction(taskId, priority), + this.data, + ); + }; + + public setHidden = (taskId: TaskId, value: boolean) => { + this.history.execute(new SetTaskHiddenAction(taskId, value), this.data); + }; + + public toggleHidden = (taskId: TaskId) => { + const task = this.getTask(taskId); + this.history.execute( + new SetTaskHiddenAction(taskId, !task.hidden), + this.data, + ); + }; + + public setName = ( + taskId: TaskId, + value: string, + path: string | undefined, + ) => { + this.history.execute( + new SetTaskNameAction(taskId, value, path), + this.data, + ); + }; + + public changeParent = (taskId: TaskId, newParentId: TaskId) => { + this.history.execute( + new ChangeParentAction(taskId, newParentId), + this.data, + ); + }; + + public getTasks = (includeDeleted = false) => { + return this.data.tasks.filter((t) => includeDeleted || !t.deleted); + }; + + public getTask = (taskId: TaskId) => { + return this.data.getTask(taskId); + }; + + public getTaskOption = (taskId: TaskId) => { + return this.data.tasks.find((t) => t.taskId == taskId) ?? null; + }; + + public getChildren = (taskId: TaskId, includeDeleted?: boolean) => { + return this.data.getChildren(taskId, includeDeleted); + }; + + public getAncestors = (taskId: TaskId) => { + return this.data.getAncestors(taskId); + }; + + public getDescendantIds = (taskId: TaskId, includeDeleted?: boolean) => { + return this.data.getDescendantIds(taskId, includeDeleted); + }; + + public isAncestorOf = (taskId: TaskId, candidate: TaskId) => { + return this.data.isAncestorOf(taskId, candidate); + }; + + public isDescendentOf = (taskId: TaskId, candidate: TaskId) => { + return this.data.isDescendentOf(taskId, candidate); + }; + + public containsBlockerPair = (blockerPair: BlockerPair) => { + return this.data.containsBlockerPair(blockerPair); + }; + + public isTaskBlocked = (taskId: TaskId) => { + if (this.getTask(taskId).status === StatusCode.DONE) { + return false; + } + return this.data.blockerPairs + .filter((p) => p.blocked === taskId) + .some((p) => this.getTask(p.blocker).status !== StatusCode.DONE); + }; + + public isTaskBlocking = (taskId: TaskId) => { + if (this.getTask(taskId).status === StatusCode.DONE) { + return false; + } + return this.data.blockerPairs + .filter((p) => p.blocker === taskId) + .some((p) => this.getTask(p.blocked).status !== StatusCode.DONE); + }; + + public isBranchHidden(id: number) { + return this.getAncestors(id).some((t) => t.hidden); + } + + public calculateStatus = (taskId: TaskId) => { + return this.data.calculateStatus(taskId); + }; +} diff --git a/src/main.ts b/src/main.ts index 0e6039e..791fbc1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { type TaskmapPluginSettings, } from "./TaskmapPluginSettings"; import { TaskmapSettingTab } from "./TaskmapSettingTab"; -import { DEFAULT_DATA } from "./ProjectData.svelte"; +import { DEFAULT_DATA } from "./data/ProjectData.svelte.js"; import { LOGO_CONTENT, LOGO_NAME } from "./IconService"; import type { TaskData } from "./types"; import { deserializeProjectData, updateFile } from "./SaveManager"; diff --git a/src/types.ts b/src/types.ts index d5b162e..9e3d33c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,7 +67,7 @@ export const buttonTextFromIconCode = (code: IconCode) => { }; export const toIconCode = (s: StatusCode) => { - return (s + IconCode.STATUS_DRAFT) as number as IconCode; + return (s + IconCode.STATUS_DRAFT) as IconCode; }; export const toStatusCode = (s: IconCode) => { @@ -92,7 +92,7 @@ export type TaskId = number; export type TaskData = { name: string; - path?: string | undefined | null; + path?: string | undefined; taskId: TaskId; status: StatusCode; deleted: boolean;