undo/redo on key press

This commit is contained in:
poanse 2026-02-15 23:59:35 +03:00
parent 043e54f867
commit 7753caba8f
17 changed files with 662 additions and 225 deletions

View file

@ -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<TaskId, Vector2>;
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<TaskId>(
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,
});
}
}

View file

@ -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) {

View file

@ -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(),
);

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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);
}

View file

@ -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 @@
);
</script>
{#if !context.isTaskHidden(taskId) && !context.projectData.isBranchHidden(taskId)}
{#if !context.isTaskHidden(taskId) && !context.versionedData.isBranchHidden(taskId)}
{#key context.updateOnZoomCounter}
<div
class="task-container"
@ -141,7 +142,6 @@
{taskId}
{isUnselected}
{context}
app={context.app}
/>
</div>
{#if !context.taskDraggingManager.isDragging
@ -150,13 +150,13 @@
&& !(context.chosenBlockerId !== NoTaskId)}
<AddTaskButton {context} {taskId} />
{/if}
{#if (context.projectData.getChildren(taskId).length > 0)
{#if (context.versionedData.getChildren(taskId).length > 0)
&& !context.isReparentingOn()
&& !(context.chosenBlockedId !== NoTaskId)
&& !(context.chosenBlockerId !== NoTaskId)}
<HideBranchButton {context} {taskId} />
{/if}
{#if context.isTaskBlocking(taskId)}
{#if context.versionedData.isTaskBlocking(taskId)}
<div
class="icon-container"
style="
@ -173,7 +173,7 @@
/>
</div>
{/if}
{#if context.isTaskBlocked(taskId)}
{#if context.versionedData.isTaskBlocked(taskId)}
<div
class="icon-container"
style="

View file

@ -1,10 +1,9 @@
<script lang="ts">
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();
}
</script>
<div
class="task-text-container"
class:selected={isSelected}

View file

@ -1,5 +1,5 @@
<script lang="ts">
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 @@
</defs>
</defs>
<g class="svg-group" bind:this={svgGroupEl}>
{#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)}
<Connection
startTaskId={task.parentId}
@ -177,7 +194,7 @@
tabindex="-1"
role="presentation"
>
{#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)}
<Task taskId={task.taskId} {context} coords={context.getCurrentTaskPosition(task.taskId)}/>
{/each}
</div>
@ -192,12 +209,12 @@
</defs>
<g class="svg-group" bind:this={svgGroupEl}>
{#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}

View file

@ -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 @@
<div
class="toolbar"
class:no-pan={true}
bind:this={self}
in:fade|global={{ duration: 500 }}
out:fade|global={{ duration: 300 }}
onclick={(e) => e.stopPropagation()}

287
src/data/Action.ts Normal file
View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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<TaskData>());
@ -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;

170
src/data/VersionedData.ts Normal file
View file

@ -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);
};
}

View file

@ -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";

View file

@ -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;