mirror of
https://github.com/poanse/obsidian-taskmap.git
synced 2026-07-22 06:05:58 +00:00
513 lines
13 KiB
TypeScript
513 lines
13 KiB
TypeScript
import TaskmapPlugin from "./main";
|
|
import {
|
|
type BlockerPair,
|
|
MouseDown,
|
|
StatusCode,
|
|
type TaskId,
|
|
type Vector2,
|
|
} from "./types";
|
|
import { Spring } from "svelte/motion";
|
|
import {
|
|
type App,
|
|
MarkdownView,
|
|
Notice,
|
|
TFile,
|
|
type WorkspaceLeaf,
|
|
} from "obsidian";
|
|
import { TaskmapView } from "./TaskmapView";
|
|
import {
|
|
type NodePositionsCalculator,
|
|
NoTaskId,
|
|
RootTaskId,
|
|
V2,
|
|
} from "./NodePositionsCalculator";
|
|
import { DraggingManager } from "./DraggingManager.svelte";
|
|
import { delink, LinkManager, tasknameFromFilePath } from "./LinkManager";
|
|
import { VersionedData } from "./data/VersionedData";
|
|
import { innerHeight } from "svelte/reactivity/window";
|
|
import { TASK_SIZE } from "./Constants";
|
|
|
|
export class Context {
|
|
app: App;
|
|
plugin: TaskmapPlugin;
|
|
nodePositionsCalculator: NodePositionsCalculator;
|
|
linkManager: LinkManager;
|
|
pressedButtonCode = $state(-1);
|
|
editingTaskId = $state(NoTaskId);
|
|
draggedTaskId = $state(NoTaskId);
|
|
selectedTaskId = $state(NoTaskId);
|
|
focusedTaskId = $state(RootTaskId);
|
|
chosenBlockerId = $state(NoTaskId);
|
|
chosenBlockedId = $state(NoTaskId);
|
|
toolbarStatus = $state(StatusCode.DRAFT);
|
|
reparentingTaskId = $state(NoTaskId);
|
|
versionedData: VersionedData;
|
|
positions: Map<TaskId, Vector2>;
|
|
taskPositions: Array<{
|
|
taskId: TaskId;
|
|
tween: Spring<{ x: number; y: number }> | null;
|
|
}>;
|
|
taskDraggingManager = new DraggingManager([MouseDown.LEFT]);
|
|
// svg elements are pixelated zooming from scale < 1 to scale > 1, so we force a redraw manually
|
|
updateOnZoomCounter = $state(0);
|
|
scale = $state(1);
|
|
|
|
// parameters for animating task movement
|
|
private tweenOptions = { duration: 300 };
|
|
private springOptions = { stiffness: 0.07, damping: 0.7 };
|
|
|
|
constructor(
|
|
app: App,
|
|
plugin: TaskmapPlugin,
|
|
projectData: VersionedData,
|
|
nodePositionsCalculator: NodePositionsCalculator,
|
|
) {
|
|
this.plugin = plugin;
|
|
this.nodePositionsCalculator = nodePositionsCalculator;
|
|
this.app = app;
|
|
this.versionedData = projectData;
|
|
this.linkManager = new LinkManager(app);
|
|
|
|
this.taskPositions = projectData.getTasks().map((task) => {
|
|
return {
|
|
taskId: task.taskId,
|
|
tween: null,
|
|
};
|
|
});
|
|
this.updateTaskPositions();
|
|
}
|
|
|
|
public isBlockerHighlighted = (taskId: TaskId) => {
|
|
if (this.versionedData.getTask(taskId).status === StatusCode.DONE) {
|
|
return false;
|
|
}
|
|
if (this.chosenBlockedId !== NoTaskId) {
|
|
return (
|
|
this.chosenBlockedId === taskId ||
|
|
this.versionedData.containsBlockerPair({
|
|
blocked: this.chosenBlockedId,
|
|
blocker: taskId,
|
|
})
|
|
);
|
|
} else if (this.chosenBlockerId !== NoTaskId) {
|
|
return (
|
|
this.chosenBlockerId === taskId ||
|
|
this.versionedData.containsBlockerPair({
|
|
blocked: taskId,
|
|
blocker: this.chosenBlockerId,
|
|
})
|
|
);
|
|
} else {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
public startTaskDragging = (e: PointerEvent, taskId: TaskId) => {
|
|
this.draggedTaskId = taskId;
|
|
this.taskDraggingManager.onPointerDown(e);
|
|
};
|
|
|
|
public finishTaskDragging = (e: PointerEvent, updatePositions = false) => {
|
|
this.draggedTaskId = NoTaskId;
|
|
this.taskDraggingManager.onPointerUp(e);
|
|
if (updatePositions) {
|
|
this.updateTaskPositions();
|
|
}
|
|
};
|
|
|
|
public setScale(scale: number) {
|
|
this.scale = scale;
|
|
}
|
|
|
|
public incrementUpdateOnZoomCounter(): void {
|
|
this.updateOnZoomCounter += 1;
|
|
}
|
|
|
|
public isValidBlockerTarget(taskId: TaskId) {
|
|
if (taskId === NoTaskId || this.chosenBlockedId === NoTaskId) {
|
|
return false;
|
|
}
|
|
if (this.versionedData.getTask(taskId).status == StatusCode.DONE) {
|
|
return false;
|
|
}
|
|
return this.isValidBlockerPairTarget({
|
|
blocker: taskId,
|
|
blocked: this.chosenBlockedId,
|
|
});
|
|
}
|
|
|
|
public isValidBlockedTarget(taskId: TaskId) {
|
|
if (taskId === NoTaskId || this.chosenBlockerId === NoTaskId) {
|
|
return false;
|
|
}
|
|
if (this.versionedData.getTask(taskId).status == StatusCode.DONE) {
|
|
return false;
|
|
}
|
|
return this.isValidBlockerPairTarget({
|
|
blocker: this.chosenBlockerId,
|
|
blocked: taskId,
|
|
});
|
|
}
|
|
|
|
private isValidBlockerPairTarget(blockerPair: BlockerPair) {
|
|
return !(
|
|
blockerPair.blocked === blockerPair.blocker ||
|
|
this.versionedData.isAncestorOf(
|
|
blockerPair.blocked,
|
|
blockerPair.blocker,
|
|
) ||
|
|
this.versionedData.isDescendentOf(
|
|
blockerPair.blocked,
|
|
blockerPair.blocker,
|
|
)
|
|
);
|
|
}
|
|
|
|
public isReparentingOn() {
|
|
return this.reparentingTaskId != NoTaskId;
|
|
}
|
|
|
|
public startReparenting(taskId: TaskId) {
|
|
this.reparentingTaskId = taskId;
|
|
}
|
|
|
|
public cancelReparenting() {
|
|
this.reparentingTaskId = NoTaskId;
|
|
}
|
|
|
|
public isValidReparentingTarget(candidate: TaskId) {
|
|
if (this.reparentingTaskId == NoTaskId) {
|
|
throw new Error("Incorrect state: reparentingTaskId expected");
|
|
}
|
|
const task = this.versionedData.getTask(this.reparentingTaskId);
|
|
return (
|
|
candidate != this.reparentingTaskId &&
|
|
candidate != task.parentId &&
|
|
!this.versionedData
|
|
.getDescendantIds(this.reparentingTaskId)
|
|
.contains(candidate)
|
|
);
|
|
}
|
|
|
|
public finishReparenting(newParentId: TaskId) {
|
|
this.pressedButtonCode = -1;
|
|
if (this.reparentingTaskId == NoTaskId) {
|
|
throw new Error("Incorrect state: reparentingTaskId expected");
|
|
}
|
|
this.versionedData.changeParent(this.reparentingTaskId, newParentId);
|
|
this.updateTaskPositions();
|
|
}
|
|
|
|
public changeFocusedTask(taskId: TaskId): void {
|
|
if (this.focusedTaskId == taskId) {
|
|
this.focusedTaskId = RootTaskId;
|
|
} else {
|
|
this.focusedTaskId = taskId;
|
|
}
|
|
this.updateTaskPositions();
|
|
}
|
|
|
|
public isAncestorOfHidden(taskId: TaskId): boolean {
|
|
return this.versionedData
|
|
.getAncestors(this.focusedTaskId)
|
|
.map((t) => t.taskId)
|
|
.contains(taskId);
|
|
}
|
|
|
|
public updateTaskPositions(draggingOnly = false) {
|
|
if (!draggingOnly) {
|
|
const newPositions =
|
|
this.nodePositionsCalculator.CalculatePositionsInGlobalFrame(
|
|
this.versionedData
|
|
.getTasks()
|
|
.filter((t) => !this.isTaskHidden(t.taskId)),
|
|
{
|
|
x: 0,
|
|
y: (innerHeight.current ?? 0) / 2 - TASK_SIZE.height,
|
|
},
|
|
);
|
|
|
|
if (this.taskDraggingManager.isDragging) {
|
|
[
|
|
this.draggedTaskId,
|
|
...this.versionedData.getDescendantIds(this.draggedTaskId),
|
|
].forEach((t) => {
|
|
const v = this.positions.get(t);
|
|
if (v !== undefined) {
|
|
newPositions.set(t, v);
|
|
}
|
|
});
|
|
}
|
|
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.versionedData.getTaskOption(t.taskId)?.deleted,
|
|
);
|
|
this.taskPositions.forEach((taskPos) => {
|
|
const newPos = this.positions.get(taskPos.taskId);
|
|
if (newPos === undefined) {
|
|
return;
|
|
}
|
|
if (taskPos.tween === null) {
|
|
taskPos.tween = new Spring(newPos, this.springOptions);
|
|
}
|
|
|
|
taskPos.tween.stiffness = this.springOptions.stiffness;
|
|
taskPos.tween.damping = this.springOptions.damping;
|
|
taskPos.tween.target = newPos;
|
|
});
|
|
}
|
|
|
|
// dragging
|
|
if (this.draggedTaskId !== NoTaskId) {
|
|
const draggingDelta = V2.mult(
|
|
{
|
|
x: this.taskDraggingManager.deltaX,
|
|
y: this.taskDraggingManager.deltaY,
|
|
},
|
|
1 / this.scale,
|
|
);
|
|
const draggedTaskIds = this.versionedData.getDescendantIds(
|
|
this.draggedTaskId,
|
|
);
|
|
this.taskPositions
|
|
.filter((t) => draggedTaskIds.contains(t.taskId))
|
|
.forEach((t) => {
|
|
if (
|
|
t.tween !== null &&
|
|
t.tween !== undefined &&
|
|
this.positions.has(t.taskId)
|
|
) {
|
|
t.tween.stiffness = 1;
|
|
t.tween.damping = 1;
|
|
t.tween.set(
|
|
V2.add(
|
|
this.positions.get(t.taskId)!,
|
|
draggingDelta,
|
|
),
|
|
);
|
|
}
|
|
});
|
|
const updated = this.updateDraggedTaskPriority();
|
|
if (updated) {
|
|
this.updateTaskPositions();
|
|
this.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateDraggedTaskPriority() {
|
|
// Если порядок тасок по оси Y отличается от приоритетов, то меняем приоритеты и пересчитываем порядок
|
|
const draggedParentId = this.versionedData.getTask(
|
|
this.draggedTaskId,
|
|
).parentId;
|
|
const orderedSiblings = this.taskPositions
|
|
.filter((t) =>
|
|
this.versionedData
|
|
.getChildren(draggedParentId)
|
|
.contains(t.taskId),
|
|
)
|
|
.sort((a, b) => a.tween?.target.y! - b.tween?.target.y!)
|
|
.map((t) => t.taskId);
|
|
const newPriority = orderedSiblings.findIndex(
|
|
(t) => t == this.draggedTaskId,
|
|
);
|
|
const oldPriority = this.versionedData.getTask(
|
|
this.draggedTaskId,
|
|
).priority;
|
|
if (newPriority != oldPriority) {
|
|
this.versionedData.setPriority(this.draggedTaskId, newPriority);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public isSelected(taskId: number) {
|
|
return this.selectedTaskId == taskId;
|
|
}
|
|
|
|
public setSelectedTaskId(taskId: number) {
|
|
this.selectedTaskId = taskId;
|
|
if (taskId != -1) {
|
|
this.toolbarStatus = this.versionedData.getTask(taskId).status;
|
|
}
|
|
}
|
|
|
|
public save() {
|
|
this.app.workspace.getActiveViewOfType(TaskmapView)?.debouncedSave();
|
|
}
|
|
|
|
public addTask(parentId: TaskId): void {
|
|
this.versionedData.addTask(parentId);
|
|
this.save();
|
|
this.updateTaskPositions();
|
|
}
|
|
|
|
public removeTaskSingle(id: number) {
|
|
this.setSelectedTaskId(-1);
|
|
this.versionedData.removeTaskSingle(id);
|
|
this.updateTaskPositions();
|
|
this.save();
|
|
}
|
|
|
|
public removeTaskBranch(id: number) {
|
|
this.setSelectedTaskId(-1);
|
|
this.versionedData.removeTaskBranch(id);
|
|
// this.taskPositions = this.taskPositions.filter((t) => t.taskId !== id);
|
|
this.updateTaskPositions();
|
|
this.save();
|
|
}
|
|
|
|
public changeStatus(status: StatusCode) {
|
|
console.debug(
|
|
`changeStatus from ${this.toolbarStatus} to ${status} for task ${this.selectedTaskId}`,
|
|
);
|
|
this.versionedData.setStatus(this.selectedTaskId, status);
|
|
this.toolbarStatus = status;
|
|
this.save();
|
|
}
|
|
|
|
public hideTaskBranch(id: number) {
|
|
this.versionedData.setHidden(id, true);
|
|
}
|
|
|
|
public unhideTaskBranch(id: number) {
|
|
this.versionedData.setHidden(id, false);
|
|
}
|
|
|
|
public getCurrentTaskPosition(taskId: number) {
|
|
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.
|
|
* Opens the note on the right side.
|
|
* @param taskId
|
|
*/
|
|
public async createLinkedNote(taskId: TaskId) {
|
|
const filepath = this.filePathFromTask(taskId);
|
|
|
|
try {
|
|
const abstractFile = this.app.vault.getAbstractFileByPath(filepath);
|
|
const tfile: TFile =
|
|
abstractFile instanceof TFile
|
|
? abstractFile
|
|
: await this.app.vault.create(
|
|
filepath,
|
|
`Created by Taskmap.`,
|
|
);
|
|
this.versionedData.setName(
|
|
taskId,
|
|
tasknameFromFilePath(filepath),
|
|
filepath,
|
|
);
|
|
this.save();
|
|
await this.app.workspace
|
|
.getActiveViewOfType(TaskmapView)
|
|
?.refreshUi();
|
|
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.versionedData.getTask(taskId);
|
|
const taskName = task.name;
|
|
// Sanitize the name for Obsidian filenames
|
|
let sanitizedName = taskName.replace(/[\\/:*?"<>|]/g, "-");
|
|
sanitizedName = delink(sanitizedName);
|
|
return `${sanitizedName}.md`;
|
|
}
|
|
|
|
/**
|
|
* 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 anotherUnpinnedLeaf = rootLeaves.reverse().find((leaf) => {
|
|
return leaf !== activeLeaf && !leaf.getViewState().pinned;
|
|
});
|
|
|
|
if (anotherUnpinnedLeaf) {
|
|
await anotherUnpinnedLeaf.openFile(file);
|
|
workspace.setActiveLeaf(anotherUnpinnedLeaf, { focus: true });
|
|
} else {
|
|
const newLeaf = workspace.getLeaf("tab");
|
|
await newLeaf.openFile(file);
|
|
}
|
|
}
|
|
|
|
public serializeForDebugging() {
|
|
return JSON.stringify({
|
|
pressedButtonIndex: this.pressedButtonCode,
|
|
selectedTaskId: this.selectedTaskId,
|
|
toolbarStatus: this.toolbarStatus,
|
|
projectData: this.versionedData,
|
|
});
|
|
}
|
|
}
|