mirror of
https://github.com/poanse/obsidian-taskmap.git
synced 2026-07-22 14:00:24 +00:00
Merge remote-tracking branch 'origin/master' into dev
This commit is contained in:
commit
8bed99e4b3
17 changed files with 445 additions and 331 deletions
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ function createProjectData(tasks: TaskData[]): ProjectData {
|
|||
blockerPairs: [],
|
||||
folderPath: undefined,
|
||||
curTaskId: tasks.length,
|
||||
taskSizeOverrides: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue