Merge remote-tracking branch 'origin/master' into dev

This commit is contained in:
poanse 2026-07-12 23:57:03 +03:00
commit 8bed99e4b3
17 changed files with 445 additions and 331 deletions

View file

@ -1,7 +1,7 @@
{
"id": "taskmap",
"name": "Taskmap",
"version": "0.1.7",
"version": "0.1.8",
"minAppVersion": "1.12.4",
"description": "Plan projects via interactive GUI task trees with automatic layout.",
"author": "poanse",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-taskmap",
"version": "0.1.7",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-taskmap",
"version": "0.1.5",
"version": "0.1.8",
"license": "MIT",
"dependencies": {
"@napolab/alpha-blend": "^2.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-taskmap",
"version": "0.1.7",
"version": "0.1.8",
"description": "Taskmap plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {

View file

@ -92,6 +92,7 @@ function createProjectData(tasks: TaskData[]): ProjectData {
blockerPairs: [],
folderPath: undefined,
curTaskId: tasks.length,
taskSizeOverrides: [],
});
}

View file

@ -106,6 +106,12 @@ export class Context {
this.hoveredBlockedId = NoTaskId;
this.pressedButtonCode = -1;
for (const taskId of projectData.taskSizeOverrides.keys()) {
const size = projectData.taskSizeOverrides.get(taskId);
if (size) {
this.versionedData.setTaskSizeOverride(taskId, size);
}
}
const sel = this.versionedData.getTaskOption(this.selectedTaskId);
if (sel === undefined || sel.deleted) {
this.selectedTaskId = NoTaskId;
@ -173,6 +179,10 @@ export class Context {
}
};
public canStartTaskDragging = (taskId: TaskId) => {
return this.editingTaskId == NoTaskId && taskId != RootTaskId;
};
public startTaskDragging = (e: PointerEvent, taskId: TaskId) => {
this.draggedTaskId = taskId;
this.taskDraggingManager.onPointerDown(e);
@ -286,6 +296,36 @@ export class Context {
.includes(taskId);
}
public setTaskHeightOverride(taskId: TaskId, heightPx: number) {
this.versionedData.setTaskSizeOverride(taskId, {
x: TASK_SIZE.width,
y: heightPx,
});
this.updateTaskPositions();
this.save();
}
public hasTaskHeightOverride(taskId: TaskId) {
return this.versionedData.hasTaskSizeOverride(taskId);
}
public clearTaskHeightOverride(taskId: TaskId) {
if (this.versionedData.hasTaskSizeOverride(taskId)) {
this.versionedData.clearTaskSizeOverride(taskId);
this.updateTaskPositions();
this.save();
}
}
public getTaskSize(taskId: TaskId) {
return (
this.versionedData.getTaskSizeOverride(taskId) ?? {
x: TASK_SIZE.width,
y: TASK_SIZE.height,
}
);
}
public updateTaskPositions(draggingOnly = false) {
if (!draggingOnly) {
const newPositions =
@ -297,6 +337,7 @@ export class Context {
x: 0,
y: (innerHeight.current ?? 0) / 2 - TASK_SIZE.height,
},
this.versionedData.getTaskSizeOverrides(),
);
if (this.taskDraggingManager.isDragging) {

View file

@ -1,5 +1,6 @@
// Assumed types and constants based on usage
import type { TaskData, TaskId, Vector2 } from "./types";
import { TASK_SIZE } from "./Constants";
export const NoTaskId = -1 as TaskId;
export const RootTaskId = 0 as TaskId;
@ -28,10 +29,36 @@ export enum AlgorithmEnum {
DoubleRow,
}
export const ParentToChildHorizontalShift = 400;
// Horizontal shift between parent and child: width + gap
export const ParentToChildHorizontalGap = 120;
type Node = {
id: TaskId;
size: Vector2;
subtreeSize: Vector2 | undefined;
siblingShift: Vector2 | undefined;
parentAlignmentShift: Vector2 | undefined;
finalShiftInParentFrame: Vector2 | undefined;
};
export class NodePositionsCalculator {
public readonly SiblingDelta = 90;
/**
* Vertical shift between siblings: height + gap
*/
public readonly SiblingVerticalGap = 10;
/**
* Additional gap between subtrees to distinguish between subtrees in a long vertical list of tasks
*/
public readonly SiblingParentsVerticalGap = 5;
/**
* Position of the parent node relative to the height of the subtree. 0 - top, 0.5 - center, 1 - bottom
*/
public readonly AlignmentRatio = 0.5;
private readonly DefaultNodeSize = {
x: TASK_SIZE.width,
y: TASK_SIZE.height,
};
public Algorithm: AlgorithmEnum = AlgorithmEnum.DefaultTree;
public subtreeWidthByHalfPriority: Map<number, number> = new Map<
number,
@ -47,8 +74,9 @@ export class NodePositionsCalculator {
public CalculatePositionsInGlobalFrame(
tasks: TaskData[],
rootPosition: Vector2,
sizes: Map<TaskId, Vector2> | undefined = undefined,
): Map<TaskId, Vector2> {
const rootFrame = this.CalculatePositionsInRootFrame(tasks);
const rootFrame = this.CalculatePositionsInRootFrame(tasks, sizes);
const result: Map<TaskId, Vector2> = new Map<TaskId, Vector2>();
for (const [key, value] of rootFrame) {
@ -62,9 +90,12 @@ export class NodePositionsCalculator {
*/
private CalculatePositionsInRootFrame(
tasks: TaskData[],
sizes: Map<TaskId, Vector2> | undefined = undefined,
): Map<TaskId, Vector2> {
const parentFramePositions =
this.CalculatePositionsInParentFrame(tasks);
const parentFramePositions = this.CalculatePositionsInParentFrame(
tasks,
sizes,
);
const positions: Map<TaskId, Vector2> = new Map<TaskId, Vector2>();
positions.set(NoTaskId, V2.Zero);
@ -92,6 +123,16 @@ export class NodePositionsCalculator {
}
positions.set(t.taskId, V2.add(parentPos, relativePos));
});
// Make root unmovable
const rootShift = positions.get(RootTaskId);
if (rootShift) {
tasks.forEach((t) => {
positions.set(
t.taskId,
V2.sub(positions.get(t.taskId)!, rootShift),
);
});
}
this.RootFramePositions = positions;
return positions;
@ -102,15 +143,10 @@ export class NodePositionsCalculator {
*/
private CalculatePositionsInParentFrame(
tasks: TaskData[],
sizes: Map<TaskId, Vector2> | undefined = undefined,
): Map<TaskId, Vector2> {
// Position of the parent node relative to the height of the subtree. 0 - top, 0.5 - center, 1 - bottom
const alignmentRatio: Vector2 = { x: 0, y: 0.5 };
// Additional shift in the form of a fraction of siblingDelta, so that siblings' children do not merge into a single list
const parentDelta: Vector2 = { x: 0, y: 0.15 };
//// Helper data structures
// Helper data structures
// Sort tasks to simplify the implementation of DP: depth DESC, parentId ASC/DESC, priority ASC
// TODO: maybe support order sorting in DataModel?
const sortedTasks = [...tasks].sort((a, b) => {
if (b.depth !== a.depth) {
return b.depth - a.depth;
@ -140,235 +176,166 @@ export class NodePositionsCalculator {
//// Calculations
// key - root id of the subtree
const subtreeSizeByNodeId: Map<TaskId, Vector2> = new Map<
TaskId,
Vector2
>();
const nodes: Map<TaskId, Node> = new Map<TaskId, Node>();
tasks.forEach((t) =>
nodes.set(t.taskId, {
id: t.taskId,
size: sizes?.get(t.taskId) ?? this.DefaultNodeSize,
subtreeSize: undefined,
finalShiftInParentFrame: undefined,
parentAlignmentShift: undefined,
siblingShift: undefined,
}),
);
const allIdsToProcess = [...sortedTasks.map((t) => t.taskId), NoTaskId];
this.CalculateSubtreeSizes(nodes, sortedTasks, childrenIdsByParentId);
// Shift of children nodes relative to the top-left point of the subtree rectangle
this.CalculateSiblingShift(nodes, parentIds, childrenIdsByParentId);
// Shift of the parent relative to the top-left point of the subtree rectangle.
// Subtract parentDelta, so that the parent is aligned with the children
this.CalculateParentAlignmentShift(nodes, parentIds);
// Shift of children nodes relative to the parent
// in pixels - not in relative coordinates
this.CalculateFinalShiftInParentFrame(nodes, sortedTasks);
const res = new Map<TaskId, Vector2>();
nodes.forEach((node) => {
res.set(node.id, node.finalShiftInParentFrame!);
});
return res;
}
/**
* Size of subtree in integer amounts of tasks
*/
private CalculateSubtreeSizes(
nodes: Map<TaskId, Node>,
sortedTasks: TaskData[],
childrenIdsByParentId: Map<TaskId, TaskId[]>,
): void {
const allIdsToProcess = sortedTasks.map((t) => t.taskId);
allIdsToProcess.forEach((id) => {
if (childrenIdsByParentId.has(id)) {
const childrenIds = childrenIdsByParentId.get(id)!;
const xAgg =
Math.max(
...childrenIds.map(
(x) => subtreeSizeByNodeId.get(x)!.x,
),
) + 1;
const yAgg = childrenIds
.map((x) => subtreeSizeByNodeId.get(x)!.y)
.reduce((a, b) => a + b, 0);
subtreeSizeByNodeId.set(
id,
V2.add({ x: xAgg, y: yAgg }, parentDelta),
);
const childrenIds = childrenIdsByParentId.get(id) ?? [];
const node = nodes.get(id);
if (node === undefined) {
throw new Error(`${id} not found in nodes`);
}
if (childrenIds.length == 0) {
node.subtreeSize = node.size;
} else {
subtreeSizeByNodeId.set(id, V2.One);
const childrenSizes = childrenIds.map(
(x) => nodes.get(x)!.subtreeSize!,
);
const maxSubtreeXSize = Math.max(
...childrenSizes.map((s) => s.x),
);
const nodeYsize =
childrenSizes.map((s) => s.y).reduce((a, b) => a + b, 0) +
(childrenSizes.length - 1) * this.SiblingVerticalGap;
node.subtreeSize = {
x:
maxSubtreeXSize +
node.size.x +
ParentToChildHorizontalGap,
y: Math.max(nodeYsize, node.size.y),
};
}
});
}
// Shift of children nodes relative to the left-top point of the rectangle
const siblingShiftsRel: Map<TaskId, Vector2> = new Map<
TaskId,
Vector2
>();
private CalculateSiblingShift(
nodes: Map<TaskId, Node>,
parentIds: TaskId[],
childrenIdsByParentId: Map<TaskId, TaskId[]>,
): void {
parentIds.forEach((parentId) => {
const children = childrenIdsByParentId.get(parentId)!;
children.forEach((childId, idx) => {
let s = V2.mult(
subtreeSizeByNodeId.get(childId)!,
alignmentRatio,
);
if (idx > 0) {
const previousSibling = children[idx - 1];
const prevSize = V2.mult(
subtreeSizeByNodeId.get(previousSibling)!,
V2.sub(V2.One, alignmentRatio),
);
s = V2.add(s, prevSize);
s = V2.add(s, siblingShiftsRel.get(previousSibling)!);
}
// Additional shift for drawing blockers
// var blockerCount = BlockerDataManager.GetConnections()
// .Count(x => x.Item1 == childId);
// s += blockerCount;
siblingShiftsRel.set(childId, s);
});
});
const childNode = nodes.get(childId)!;
const xShift = childNode.size.x + ParentToChildHorizontalGap;
if (idx == 0) {
const parentNodeSize =
nodes.get(parentId)?.size ?? this.DefaultNodeSize; // in case of -1
const childrenYSumWithGaps =
children
.map((x) => nodes.get(x)!.subtreeSize!.y)
.reduce((a, b) => a + b, 0) +
(children.length - 1) * this.SiblingVerticalGap;
childNode.siblingShift = {
x: xShift,
y: Math.max(
0,
(parentNodeSize.y - childrenYSumWithGaps) / 2,
),
};
} else if (idx > 0) {
const prevSiblingId = children[idx - 1];
const prevSiblingNode = nodes.get(prevSiblingId)!;
const siblingBranchesHaveChildren =
(childrenIdsByParentId.get(childId)?.length ?? 0) > 0 &&
(childrenIdsByParentId.get(prevSiblingId)?.length ??
0) > 0;
// Shift of the parent relative to the left-top point of the rectangle.
// Subtract parentDelta, so that the parent is aligned with the children
const parentAlignmentShift: Map<TaskId, Vector2> = new Map<
TaskId,
Vector2
>();
parentIds.forEach((parentId) => {
if (!subtreeSizeByNodeId.has(parentId)) {
throw new Error(
`Failed to calculate layout: taskId [${parentId}] not in subtreeSizeByNodeId`,
);
}
parentAlignmentShift.set(
parentId,
V2.mult(
V2.sub(subtreeSizeByNodeId.get(parentId)!, parentDelta),
alignmentRatio,
),
);
});
// Shift of children nodes relative to the parent and already in normal coordinates
const finalChildShifts: Map<TaskId, Vector2> = new Map<
TaskId,
Vector2
>();
sortedTasks.forEach((t) => {
const parentShift = parentAlignmentShift.get(t.parentId);
const siblingShift = siblingShiftsRel.get(t.taskId);
if (parentShift === undefined) {
throw new Error();
}
if (siblingShift === undefined) {
throw new Error();
}
const verticalComponent = V2.mult(
{ x: 0, y: this.SiblingDelta },
V2.sub(siblingShift, parentShift),
);
finalChildShifts.set(
t.taskId,
V2.add(
{ x: ParentToChildHorizontalShift, y: 0 },
verticalComponent,
),
);
});
const rootTaskHasMultipleVisibleChildren =
(childrenIdsByParentId.get(RootTaskId)! || []).length > 1;
if (
rootTaskHasMultipleVisibleChildren &&
[AlgorithmEnum.SingleRow, AlgorithmEnum.DoubleRow].includes(
this.Algorithm,
)
) {
const rowMinYShift = 0.3;
const depthOneTasks = sortedTasks.filter((x) => x.depth === 1);
// Group by RowIndex
const depthOneTasksByRow: Map<number, TaskData[]> = new Map<
number,
TaskData[]
>();
depthOneTasks.forEach((t) => {
const idx = this.RowIndex(t);
if (!depthOneTasksByRow.has(idx)) {
depthOneTasksByRow.set(idx, []);
}
depthOneTasksByRow.get(idx)!.push(t);
});
const yShiftByRowId: Map<number, number> = new Map<
number,
number
>();
[...depthOneTasksByRow.keys()].forEach((key) => {
const rowIdx = Number(key);
const elements = depthOneTasksByRow.get(rowIdx)!;
const shifts = elements.map((n) =>
parentAlignmentShift.has(n.taskId)
? parentAlignmentShift.get(n.taskId)!.y
: V2.mult(alignmentRatio, V2.One).y,
);
const maxYShift = shifts.length > 0 ? Math.max(...shifts) : 0; // MaxOrDefault(0) equivalent
yShiftByRowId.set(
rowIdx,
(maxYShift + rowMinYShift) * this.SiblingDelta,
);
});
if (this.Algorithm === AlgorithmEnum.SingleRow) {
let subtreeRelWidthAccumulator = this.xshift;
depthOneTasks.forEach((x) => {
finalChildShifts.set(x.taskId, {
x:
finalChildShifts.get(x.taskId)!.x +
subtreeRelWidthAccumulator *
ParentToChildHorizontalShift,
y: yShiftByRowId.get(this.RowIndex(x))!,
});
subtreeRelWidthAccumulator +=
subtreeSizeByNodeId.get(x.taskId)!.x + this.xshift;
});
} else if (this.Algorithm === AlgorithmEnum.DoubleRow) {
// Sizes of subtrees by x. If 2 tasks are above each other, the larger one is used.
this.subtreeWidthByHalfPriority = new Map<number, number>();
const rootChildrenByPriority: Map<number, TaskId> = new Map<
number,
TaskId
>();
const rootChildren =
childrenIdsByParentId.get(RootTaskId) ?? [];
rootChildren.forEach((id, idx) => {
// reenumerate priorities based on index
rootChildrenByPriority.set(idx, id);
});
rootChildrenByPriority.forEach((_priority, chId) => {
const x = taskById.get(chId)!;
if (this.RowIndex(x) === 0) {
const neighbourId =
rootChildrenByPriority.get(x.priority + 1) ??
x.taskId;
this.subtreeWidthByHalfPriority.set(
Math.floor(x.priority / 2),
Math.max(
subtreeSizeByNodeId.get(x.taskId)!.x,
subtreeSizeByNodeId.get(neighbourId)!.x,
),
);
}
});
depthOneTasks.forEach((x) => {
const t = [...this.subtreeWidthByHalfPriority]
.filter(([k, _]) => k < Math.floor(x.priority / 2))
.map(([_, v]) => v);
const tSum = t.reduce((a, b) => a + b, 0);
finalChildShifts.set(x.taskId, {
x:
finalChildShifts.get(x.taskId)!.x +
(this.xshift + this.xshift * t.length + tSum) *
ParentToChildHorizontalShift,
childNode.siblingShift = {
x: xShift,
y:
this.RowIndex(x) === 0
? yShiftByRowId.get(this.RowIndex(x))!
: -yShiftByRowId.get(this.RowIndex(x))!,
});
});
}
}
return finalChildShifts;
prevSiblingNode.siblingShift!.y +
prevSiblingNode.subtreeSize!.y +
this.SiblingVerticalGap +
(siblingBranchesHaveChildren
? this.SiblingParentsVerticalGap
: 0),
};
}
});
});
}
public RowIndex(node: TaskData): number {
switch (this.Algorithm) {
case AlgorithmEnum.SingleRow:
return 0;
case AlgorithmEnum.DoubleRow:
return node.priority % 2;
default:
throw new Error("AssumptionViolation");
}
private CalculateParentAlignmentShift(
nodes: Map<TaskId, Node>,
parentIds: TaskId[],
) {
parentIds
.filter((p) => p != NoTaskId)
.forEach((taskId) => {
const node = nodes.get(taskId);
if (node === undefined) {
throw new Error(`Task id ${taskId} is missing from nodes`);
}
if (!node?.subtreeSize) {
throw new Error(
`Failed to calculate layout: taskId [${taskId}] not in subtreeSizeByNodeId`,
);
}
if (this.AlignmentRatio == 0.5) {
const yShift = (node.subtreeSize.y - node.size.y) / 2;
node.parentAlignmentShift = { x: 0, y: yShift };
} else {
throw new Error("Not implemented");
}
});
}
private CalculateFinalShiftInParentFrame(
nodes: Map<TaskId, Node>,
sortedTasks: TaskData[],
) {
sortedTasks
.filter((t) => t.taskId != NoTaskId)
.forEach((t) => {
const parentNode = nodes.get(t.parentId);
const parentShift = parentNode?.parentAlignmentShift ?? V2.Zero;
const taskNode = nodes.get(t.taskId)!;
taskNode.finalShiftInParentFrame = V2.sub(
V2.add(
taskNode.siblingShift!,
taskNode.parentAlignmentShift ?? V2.Zero,
),
parentShift,
);
});
}
}

View file

@ -16,6 +16,9 @@ export function serializeProjectData(projectData: ProjectData) {
blockerPairs: projectData.blockerPairs,
folderPath: projectData.folderPath,
curTaskId: projectData.curTaskId,
taskSizeOverrides: [...projectData.taskSizeOverrides.entries()].map(
([taskId, { x, y }]) => ({ taskId, x, y }),
),
},
null,
2,

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { TASK_SIZE } from "../Constants";
import { StatusCode } from "../types";
import { Context } from "../Context.svelte.js";
@ -21,10 +20,7 @@
role="group"
onpointerenter={() => entered = true}
onpointerleave={() => entered = false}
style="
left: {TASK_SIZE.width - 50/2}px;
top: {TASK_SIZE.height/2 - 50/2}px;
"
style="left: {context.getTaskSize(taskId).x - 50/2}px;"
>
{#if entered}
<svg
@ -50,6 +46,8 @@
position: absolute;
width: 50px;
height: 50px;
top: 50%;
transform: translateY(-50%);
/* Centering logic for the SVG */
display: flex;

View file

@ -1,8 +1,7 @@
<script lang="ts">
import type {TaskId, Vector2} from "../types";
import {NoTaskId, ParentToChildHorizontalShift, V2} from "../NodePositionsCalculator";
import {NoTaskId, ParentToChildHorizontalGap, V2} from "../NodePositionsCalculator";
import type {Context} from "../Context.svelte";
import {TASK_SIZE} from "../Constants";
let {startTaskId, endTaskId, context, isBlockerConnection}: {
startTaskId: TaskId,
@ -15,25 +14,28 @@
return context.versionedData.getTask(start).depth <= context.versionedData.getTask(end).depth;
}
function getConnectionPointShift(start: TaskId, end: TaskId) {
return {
x: isStartTaskDepthLE(start, end) ? TASK_SIZE.width : 0,
y: TASK_SIZE.height/2
};
}
let startTaskSize = $derived(context.getTaskSize(startTaskId));
let endTaskSize = $derived(context.getTaskSize(endTaskId));
let startPoint = $derived(V2.add(
context.getCurrentTaskPosition(startTaskId),
getConnectionPointShift(startTaskId, endTaskId)
{
x: isStartTaskDepthLE(startTaskId, endTaskId) ? startTaskSize.x : 0,
y: startTaskSize.y / 2
}
));
let endPoint = $derived(V2.add(
context.getCurrentTaskPosition(endTaskId),
getConnectionPointShift(endTaskId, startTaskId)
{
x: isStartTaskDepthLE(endTaskId, startTaskId) ? endTaskSize.x : 0,
y: endTaskSize.y / 2
}
));
let midX = $derived(
isBlockerConnection
? (context.chosenBlockerId !== NoTaskId
? endPoint.x + (isStartTaskDepthLE(endTaskId, startTaskId) ? 1 : -1) * (ParentToChildHorizontalShift - TASK_SIZE.width) / 2
: startPoint.x + (isStartTaskDepthLE(startTaskId, endTaskId) ? 1 : -1) * (ParentToChildHorizontalShift - TASK_SIZE.width) / 2
? endPoint.x + (isStartTaskDepthLE(endTaskId, startTaskId) ? 1 : -1) * ParentToChildHorizontalGap / 2
: startPoint.x + (isStartTaskDepthLE(startTaskId, endTaskId) ? 1 : -1) * ParentToChildHorizontalGap / 2
)
: (startPoint.x + endPoint.x) / 2
);

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { TASK_SIZE } from "../Constants";
import { Context } from "../Context.svelte.js";
import {ParentToChildHorizontalShift} from "../NodePositionsCalculator";
import {ParentToChildHorizontalGap} from "../NodePositionsCalculator";
import { Eye, EyeClosed } from 'lucide-svelte';
const { taskId, context }: { taskId: number, context: Context } = $props();
@ -15,6 +14,11 @@
event.stopPropagation();
context.finishTaskDragging(event, true);
}
// should be in line with css below
const buttonHalfSize = 25;
let taskSize = $derived(context.getTaskSize(taskId));
let left = $derived(taskSize.x + ParentToChildHorizontalGap / 2 - buttonHalfSize);
let top = $derived(taskSize.y / 2 - buttonHalfSize);
</script>
<div
@ -23,8 +27,8 @@
onpointerenter={() => entered = true}
onpointerleave={() => entered = false}
style="
left: {TASK_SIZE.width / 2 + ParentToChildHorizontalShift / 2 - 50/2}px;
top: {TASK_SIZE.height / 2 - 50 / 2}px;
left: {left}px;
top: {top}px;
"
>
{#if taskData.hidden}

View file

@ -27,7 +27,7 @@
viewport?.focus();
};
});
let taskData = $derived(context.versionedData.getTask(taskId));
// let coords = $derived(context.getTaskPosition(taskId));
let isHovered = $state(false);
@ -126,8 +126,6 @@
left: {coords.x}px;
pointer-events={context.taskDraggingManager.isDragging ? 'none' : 'auto'}
"
onpointerenter={() => isHovered = true}
onpointerleave={() => isHovered = false}
>
<div
class={`task ${classStringFromStatusCode(taskData.status)}`}
@ -140,8 +138,10 @@
}
class:unselect={isUnselected}
class:blocker-highlight={isBlockerHighlight}
onpointerenter={() => isHovered = true}
onpointerleave={() => isHovered = false}
onpointerdown={(e: PointerEvent) => {
if (context.editingTaskId === NoTaskId) {
if (context.canStartTaskDragging(taskId)) {
context.startTaskDragging(e, taskId);
}
e.stopPropagation();
@ -152,13 +152,13 @@
tabindex="-1"
>
<TaskText {taskId} {isUnselected} {context}/>
</div>
{#if !context.taskDraggingManager.isDragging
&& !context.isReparentingOn()
&& !(context.chosenBlockedId !== NoTaskId)
&& !(context.chosenBlockerId !== NoTaskId)}
<AddTaskButton {context} {taskId} />
{/if}
</div>
{#if (context.versionedData.getChildren(taskId).length > 0)
&& !context.isReparentingOn()
&& !(context.chosenBlockedId !== NoTaskId)
@ -222,7 +222,9 @@
font-family: var(--font-text);
line-height: 1.5;
width: 280px;
height: 80px;
height: auto;
min-height: 80px;
padding: 10px 0;
display: flex;
justify-content: center;
align-items: center;
@ -234,7 +236,7 @@
}
.task.hovered {
width: 284px;
height: 84px;
min-height: 84px;
border-width: 4px;
transform: translate3d(-2px,-2px,0);
}

View file

@ -11,6 +11,7 @@
taskPathFromFile
} from "../LinkManager";
import {NoTaskId} from "../NodePositionsCalculator";
import {TASK_SIZE} from "../Constants";
let {
taskId,
@ -31,29 +32,25 @@
let textEditEl = $state<HTMLTextAreaElement | undefined>(undefined);
let component = new Component(); // Required by Obsidian to manage render lifecycle
let isDragging = $derived(context.taskDraggingManager.isDragging);
onMount(() => {
if(!isEditing && textPreviewEl) {
renderMarkdown();
}
renderMarkdown();
return () => {
// Clean up Obsidian component references to prevent memory leaks
component.unload();
};
});
$effect(() => {
if (textPreviewEl) {
renderMarkdown();
}
renderMarkdown();
if (context.taskDraggingManager.isDragging) {
document.body.classList.add('is-dragging-task');
} else {
document.body.classList.remove('is-dragging-task');
}
// Cleanup function for when component is unmounted
return () => document.body.classList.remove('is-dragging-task');
});
function handlePreviewClick(e: PointerEvent) {
if (isDragging) {
return;
@ -98,7 +95,7 @@
}
async function renderMarkdown() {
if (!textPreviewEl) {
if (!textPreviewEl || isEditing) {
return;
}
textPreviewEl.empty(); // Clear previous render
@ -117,6 +114,22 @@
// Optional: prevent dragging on the specific link level too
(link as HTMLElement).ondragstart = (e) => e.preventDefault();
});
// Guard after async gap: isEditing may have become true while renderMarkdown was awaiting.
// textPreviewEl would be unmounted at that point, so offsetHeight would be zero/stale.
// The override set before editing remains valid while editing.
if (isEditing) return;
const taskEl = textPreviewEl.closest('.task') as HTMLElement | null;
if (taskEl) {
const height = taskEl.offsetHeight;
const isHeightExpanded = height > TASK_SIZE.height_hovered;
if (isHeightExpanded) {
context.setTaskHeightOverride(taskId, height);
} else {
// noop if no override present
context.clearTaskHeightOverride(taskId);
}
}
}
async function toggleEdit() {
@ -134,10 +147,7 @@
if (!el) {
return;
}
if (e.key === "Enter") {
e.preventDefault();
el.blur(); // Triggers handleBlur
} else if (e.key === "Tab" && suggest !== null) {
if (e.key === "Tab" && suggest !== null) {
// another hack to select suggest on tab
e.preventDefault();
el.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
@ -201,7 +211,6 @@
<textarea
class="text-edit tasktext"
class:unselect={isUnselected}
maxlength="28"
bind:this={textEditEl}
onblur={handleBlur}
onkeydown={handleKeydown}
@ -224,18 +233,18 @@
--task-width: 180px;
--task-height: 60px;
--task-line-height: 1.1;
/*--task-line-height: 1.5;*/
--task-font-size: 20px;
}
.task-text-container {
width: var(--task-width);
height: var(--task-height);
max-height: 400px;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
align-items: flex-start;
position: relative;
padding: 0;
gap: 0;
overflow: hidden;
}
.task-text-container.selected .tasktext:hover {
cursor: text;
@ -260,7 +269,7 @@
outline: none;
box-shadow: none;
/*padding: 35px;*/
position: absolute;
position: relative;
text-align: center;
justify-content: center;
align-items: center;
@ -272,9 +281,9 @@
}
.text-edit {
display: flex;
display: block;
field-sizing: content;
padding-top: 5px; /* fixes text jumping between text-edit and text-preview */
padding-top: 1px; /* fixes text jumping between text-edit and text-preview */
}
.text-edit:hover {
background-color: transparent;
@ -285,24 +294,19 @@
box-shadow: none;
}
.text-preview {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
display: block;
line-clamp: 2;
height: auto;
overflow: visible;
white-space: pre-wrap;
word-wrap: break-word;
:global {
p {
top: 0;
width: var(--task-width);
height: var(--task-height);
line-height: var(--task-line-height);
margin: 0;
padding: 0;
gap: 0;
border: none;
text-align: center;
justify-content: center;
align-items: center;
overflow: visible;
}
overflow-wrap: anywhere;
padding-top: 1px;
position: relative;
:global(p) {
display: block;
margin: 0 !important;
padding: 0 !important;
}
}
</style>

View file

@ -170,20 +170,20 @@
</marker>
</defs>
<g class="svg-group">
{#key context.versionedData.getTasksVersion()}
{#each (context.versionedData.getTasks()
.filter(t => !context.isTaskHidden(t.taskId))
.filter(t => t.taskId !== RootTaskId)
.filter(t => !context.versionedData.isBranchHidden(t.taskId))
) as task (task.taskId)}
<Connection
startTaskId={task.parentId}
endTaskId={task.taskId}
{context}
isBlockerConnection={false}
/>
{/each}
{/key}
{#key context.versionedData.getConnectionsVersion()}
{#each (context.versionedData.getTasks()
.filter(t => !context.isTaskHidden(t.taskId))
.filter(t => t.taskId !== RootTaskId)
.filter(t => !context.versionedData.isBranchHidden(t.taskId))
) as task (task.taskId)}
<Connection
startTaskId={task.parentId}
endTaskId={task.taskId}
{context}
isBlockerConnection={false}
/>
{/each}
{/key}
</g>
</svg>
@ -191,7 +191,7 @@
class="task-layer"
role="presentation"
>
{#key context.versionedData.getTasksVersion()}
{#key context.versionedData.getViewUpdateCounter()}
{#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}

View file

@ -1,4 +1,9 @@
import { type BlockerPair, StatusCode, type TaskId } from "../types";
import {
type BlockerPair,
StatusCode,
type TaskId,
type Vector2,
} from "../types";
import { ProjectData } from "./ProjectData.svelte";
export interface Action {
@ -70,7 +75,7 @@ export class RemoveTaskSingleAction implements Action {
});
data.recalcPriorities(task.parentId);
data.recalcStatusRecursive(task.parentId);
data.markTasksUpdated();
data.updateTasksView();
}
undo(data: ProjectData) {
@ -159,6 +164,8 @@ export class SetTaskNameAction implements Action {
private newPath?: string;
private oldName?: string;
private oldPath?: string;
// undefined = not yet recorded; null = no override existed; Vector2 = override existed
private oldSizeOverride: Vector2 | null | undefined = undefined;
constructor(taskId: TaskId, newName: string, path?: string) {
this.taskId = taskId;
@ -169,20 +176,29 @@ export class SetTaskNameAction implements Action {
do(data: ProjectData): void {
this.oldName = data.getTask(this.taskId).name;
this.oldPath = data.getTask(this.taskId).path;
this.oldSizeOverride = data.getTaskSizeOverride(this.taskId) ?? null;
const task = data.getTask(this.taskId);
task.name = this.newName;
task.path = this.newPath;
data.updateConnectionsView();
}
undo(data: ProjectData): void {
if (this.oldName === undefined) {
if (this.oldName === undefined || this.oldSizeOverride === undefined) {
throw new Error();
}
const task = data.getTask(this.taskId);
task.name = this.oldName;
task.path = this.oldPath;
if (this.oldSizeOverride !== null) {
data.setTaskSizeOverride(this.taskId, this.oldSizeOverride);
} else {
data.deleteTaskSizeOverride(this.taskId);
}
data.updateConnectionsView();
this.oldName = undefined;
this.oldPath = undefined;
this.oldSizeOverride = undefined;
}
shouldCombine(newAction: SetTaskNameAction) {
@ -234,7 +250,7 @@ export class ChangeParentAction implements Action {
do(data: ProjectData) {
this.oldParentId = data.getTask(this.taskId).parentId;
this.oldPriority = data.getTask(this.taskId).priority;
data.getTask(this.taskId).priority = data.getChildren(this.newParentId).length;
data.getTask(this.taskId).priority = -1;
data.changeParent(this.taskId, this.newParentId);
data.recalcPriorities(this.newParentId);
data.recalcPriorities(this.oldParentId);

View file

@ -3,6 +3,7 @@
StatusCode,
type TaskData,
type TaskId,
type Vector2,
} from "../types";
import { SvelteMap } from "svelte/reactivity";
import { NoTaskId, RootTaskId } from "../NodePositionsCalculator";
@ -18,10 +19,12 @@ export class ProjectData {
childrenCache = new SvelteMap<TaskId, TaskId[]>();
ancestorsCache = new SvelteMap<TaskId, TaskId[]>();
descendantsCache = new SvelteMap<TaskId, TaskId[]>();
tasksVersion: number;
tasksViewUpdateCounter: number;
connectionsViewUpdateCounter: number;
blockerPairs: Array<BlockerPair>;
folderPath: string | undefined;
curTaskId = RootTaskId;
taskSizeOverrides: SvelteMap<TaskId, Vector2>;
public static getDefault(): ProjectData {
return new ProjectData({
@ -30,28 +33,44 @@ export class ProjectData {
blockerPairs: new Array<BlockerPair>(),
folderPath: undefined,
curTaskId: 0,
taskSizeOverrides: [],
});
}
constructor(obj: ProjectFileParsed) {
// persistent data from disk
this.tasks = $state(obj.tasks);
this.blockerPairs = $state(obj.blockerPairs ?? []);
this.tasksVersion = $state(0);
this.folderPath = obj.folderPath;
this.curTaskId = obj.curTaskId;
this.taskSizeOverrides = new SvelteMap(
(obj.taskSizeOverrides ?? []).map(({ taskId, x, y }) => [
taskId,
{ x, y },
]),
);
// temporary in-memory properties
this.tasksViewUpdateCounter = $state(0);
this.connectionsViewUpdateCounter = $state(0);
// root task must always be present
if (this.tasks.length == 0) {
this.addRootTask();
}
// initialize auxiliary data structures
this.rebuildCaches();
// fix broken priorities in old project versions
if ((obj.schemaVersion ?? 0) < TASKMAP_FILE_SCHEMA_VERSION) {
for (const parentId of this.childrenCache.keys()) {
this.recalcPriorities(parentId);
}
this.tasks.forEach((t) => this.recalcPriorities(t.taskId));
}
}
public markTasksUpdated() {
this.tasksVersion += 1;
public updateTasksView() {
this.tasksViewUpdateCounter += 1;
this.updateConnectionsView();
}
public updateConnectionsView() {
this.connectionsViewUpdateCounter += 1;
}
private rebuildCaches() {
@ -119,7 +138,7 @@ export class ProjectData {
public addTask(task: TaskData) {
this.tasks.push(task);
this.rebuildCaches();
this.markTasksUpdated();
this.updateTasksView();
this.curTaskId++;
}
@ -127,7 +146,7 @@ export class ProjectData {
const task = this.tasks.pop();
if (task) {
this.rebuildCaches();
this.markTasksUpdated();
this.updateTasksView();
this.curTaskId--;
}
}
@ -296,6 +315,22 @@ export class ProjectData {
this.blockerPairs.push(blockerPair);
};
public setTaskSizeOverride(taskId: TaskId, size: Vector2) {
this.taskSizeOverrides.set(taskId, size);
}
public deleteTaskSizeOverride(taskId: TaskId) {
this.taskSizeOverrides.delete(taskId);
}
public getTaskSizeOverride(taskId: TaskId): Vector2 | undefined {
return this.taskSizeOverrides.get(taskId);
}
public hasTaskSizeOverride(taskId: TaskId): boolean {
return this.taskSizeOverrides.has(taskId);
}
public getFolderPath = (): string | undefined => {
return this.folderPath;
};

View file

@ -2,11 +2,12 @@ import * as v from "valibot";
import type { FlatErrors } from "valibot";
import { StatusCode, type BlockerPair, type TaskData } from "../types";
/**
/**
* Bump when the on-disk JSON shape changes (migrations can branch on this).
* v2: normalize sibling priorities after reparent
* v3: added taskSizeOverride
*/
export const TASKMAP_FILE_SCHEMA_VERSION = 2 as const;
export const TASKMAP_FILE_SCHEMA_VERSION = 3 as const;
const statusCodeSchema = v.picklist([
StatusCode.DRAFT,
@ -32,6 +33,14 @@ const blockerPairSchema = v.object({
blocked: v.pipe(v.number(), v.integer()),
});
const taskSizeOverrideSchema = v.object({
taskId: v.pipe(v.number(), v.integer()),
x: v.number(),
y: v.number(),
});
export type TaskSizeOverrideEntry = { taskId: number; x: number; y: number };
export const projectFileSchema = v.object({
schemaVersion: v.optional(
v.pipe(
@ -45,6 +54,7 @@ export const projectFileSchema = v.object({
blockerPairs: v.optional(v.array(blockerPairSchema)),
folderPath: v.optional(v.string()),
curTaskId: v.pipe(v.number(), v.integer()),
taskSizeOverrides: v.optional(v.array(taskSizeOverrideSchema)),
});
export type ProjectFileParsed = {
@ -53,6 +63,7 @@ export type ProjectFileParsed = {
blockerPairs: BlockerPair[];
folderPath: string | undefined;
curTaskId: number;
taskSizeOverrides: TaskSizeOverrideEntry[];
};
export class TaskmapDataError extends Error {
@ -98,5 +109,6 @@ export function parseProjectFileJson(parsed: unknown): ProjectFileParsed {
blockerPairs: o.blockerPairs ?? [],
folderPath: o.folderPath,
curTaskId: o.curTaskId,
taskSizeOverrides: o.taskSizeOverrides ?? [],
};
}

View file

@ -1,5 +1,10 @@
import { HistoryManager } from "./HistoryManager.svelte";
import { type BlockerPair, StatusCode, type TaskId } from "../types";
import {
type BlockerPair,
StatusCode,
type TaskId,
type Vector2,
} from "../types";
import { ProjectData } from "./ProjectData.svelte";
import {
AddTaskAction,
@ -124,6 +129,26 @@ export class VersionedData {
);
};
public setTaskSizeOverride = (taskId: TaskId, size: Vector2) => {
this.data.setTaskSizeOverride(taskId, size);
};
public clearTaskSizeOverride = (taskId: TaskId) => {
this.data.deleteTaskSizeOverride(taskId);
};
public getTaskSizeOverride = (taskId: TaskId): Vector2 | undefined => {
return this.data.getTaskSizeOverride(taskId);
};
public hasTaskSizeOverride = (taskId: TaskId): boolean => {
return this.data.hasTaskSizeOverride(taskId);
};
public getTaskSizeOverrides = (): Map<TaskId, Vector2> => {
return this.data.taskSizeOverrides;
};
public getFolderPath = (): string | undefined => {
return this.data.getFolderPath();
};
@ -136,8 +161,12 @@ export class VersionedData {
return this.data.getTasks(includeDeleted);
};
public getTasksVersion = () => {
return this.data.tasksVersion;
public getViewUpdateCounter = () => {
return this.data.tasksViewUpdateCounter;
};
public getConnectionsVersion = () => {
return this.data.connectionsViewUpdateCounter;
};
public getTask = (taskId: TaskId) => {