diff --git a/package-lock.json b/package-lock.json index fc1e8d3..264a212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "obsidian-taskmap", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "dependencies": { "@napolab/alpha-blend": "^2.0.0", diff --git a/scripts/perf-tasks.ts b/scripts/perf-tasks.ts index 741bad8..27cf8a5 100644 --- a/scripts/perf-tasks.ts +++ b/scripts/perf-tasks.ts @@ -87,10 +87,12 @@ function randomTaskIds(count: number, picks: number): TaskId[] { function createProjectData(tasks: TaskData[]): ProjectData { return new ProjectData({ + schemaVersion: undefined, tasks: tasks.map((t) => ({ ...t })), blockerPairs: [], folderPath: undefined, curTaskId: tasks.length, + taskSizeOverrides: [], }); } diff --git a/src/Context.svelte.ts b/src/Context.svelte.ts index 2f5adb2..c1ec33c 100644 --- a/src/Context.svelte.ts +++ b/src/Context.svelte.ts @@ -63,7 +63,6 @@ export class Context { // svg elements are pixelated zooming from scale < 1 to scale > 1, so we force a redraw manually updateOnZoomCounter = $state(0); scale = $state(1); - private taskHeightOverrides = new Map(); // parameters for animating task movement private springOptions = { stiffness: 0.07, damping: 0.7 }; @@ -107,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; @@ -174,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); @@ -288,17 +297,35 @@ export class Context { } public setTaskHeightOverride(taskId: TaskId, heightPx: number) { - this.taskHeightOverrides.set(taskId, heightPx); + 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.taskHeightOverrides.has(taskId)) { - this.taskHeightOverrides.delete(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 = @@ -310,7 +337,7 @@ export class Context { x: 0, y: (innerHeight.current ?? 0) / 2 - TASK_SIZE.height, }, - this.taskHeightOverrides, + this.versionedData.getTaskSizeOverrides(), ); if (this.taskDraggingManager.isDragging) { @@ -406,7 +433,15 @@ export class Context { ) .sort((left, right) => { if (left.tween !== null && right.tween !== null) { - return left.tween?.target.y - right.tween?.target.y; + // если я перемещаю правую вверх, то сравнивать правую верхнюю и левую центральную точки + // если я перемещаю правую вниз, то правую нижнюю и левую центральную. + const leftY = + left.tween?.target.y - + this.getTaskSize(left.taskId).y / 2; + const rightY = + right.tween?.target.y - + this.getTaskSize(right.taskId).y / 2; + return leftY - rightY; } return 0; }) diff --git a/src/NodePositionsCalculator.ts b/src/NodePositionsCalculator.ts index 45dba62..6b273f4 100644 --- a/src/NodePositionsCalculator.ts +++ b/src/NodePositionsCalculator.ts @@ -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 = new Map< number, @@ -47,9 +74,9 @@ export class NodePositionsCalculator { public CalculatePositionsInGlobalFrame( tasks: TaskData[], rootPosition: Vector2, - taskHeightOverrides: Map = new Map(), + sizes: Map | undefined = undefined, ): Map { - const rootFrame = this.CalculatePositionsInRootFrame(tasks, taskHeightOverrides); + const rootFrame = this.CalculatePositionsInRootFrame(tasks, sizes); const result: Map = new Map(); for (const [key, value] of rootFrame) { @@ -63,10 +90,12 @@ export class NodePositionsCalculator { */ private CalculatePositionsInRootFrame( tasks: TaskData[], - taskHeightOverrides: Map, + sizes: Map | undefined = undefined, ): Map { - const parentFramePositions = - this.CalculatePositionsInParentFrame(tasks, taskHeightOverrides); + const parentFramePositions = this.CalculatePositionsInParentFrame( + tasks, + sizes, + ); const positions: Map = new Map(); positions.set(NoTaskId, V2.Zero); @@ -94,6 +123,13 @@ 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; @@ -104,16 +140,10 @@ export class NodePositionsCalculator { */ private CalculatePositionsInParentFrame( tasks: TaskData[], - taskHeightOverrides: Map, + sizes: Map | undefined = undefined, ): Map { - // 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; @@ -143,244 +173,166 @@ export class NodePositionsCalculator { //// Calculations // key - root id of the subtree - const subtreeSizeByNodeId: Map = new Map< - TaskId, - Vector2 - >(); + const nodes: Map = new Map(); + 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); - const defaultHeightPx = 80; - const gapPx = this.SiblingDelta - defaultHeightPx; + // 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(); + nodes.forEach((node) => { + res.set(node.id, node.finalShiftInParentFrame!); + }); + return res; + } + /** + * Size of subtree in integer amounts of tasks + */ + private CalculateSubtreeSizes( + nodes: Map, + sortedTasks: TaskData[], + childrenIdsByParentId: Map, + ): void { + const allIdsToProcess = sortedTasks.map((t) => t.taskId); allIdsToProcess.forEach((id) => { - const heightPx = taskHeightOverrides.get(id as TaskId) ?? defaultHeightPx; - const minYFromHeight = (heightPx + gapPx) / this.SiblingDelta; - - 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); - // If this task has a height override, ensure its allocated region is - // tall enough for the expanded card to fit without overlapping siblings. - subtreeSizeByNodeId.set( - id, - V2.add({ x: xAgg, y: Math.max(yAgg, minYFromHeight) }, 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 { - const ySizeUnits = minYFromHeight; - subtreeSizeByNodeId.set(id, { x: 1, y: ySizeUnits }); + 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 = new Map< - TaskId, - Vector2 - >(); + private CalculateSiblingShift( + nodes: Map, + parentIds: TaskId[], + childrenIdsByParentId: Map, + ): 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)!.size.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 = 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 = 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 = 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 = 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(); - - const rootChildrenByPriority: Map = 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, + 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, + 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, + ); + }); } } diff --git a/src/SaveManager.ts b/src/SaveManager.ts index 439373f..10a7c3e 100644 --- a/src/SaveManager.ts +++ b/src/SaveManager.ts @@ -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, diff --git a/src/components/AddTaskButton.svelte b/src/components/AddTaskButton.svelte index eecd552..ea834e2 100644 --- a/src/components/AddTaskButton.svelte +++ b/src/components/AddTaskButton.svelte @@ -1,5 +1,4 @@ 
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} diff --git a/src/components/Task.svelte b/src/components/Task.svelte index 026e458..6cc6b8a 100644 --- a/src/components/Task.svelte +++ b/src/components/Task.svelte @@ -142,7 +142,7 @@ onpointerenter={() => isHovered = true} onpointerleave={() => isHovered = false} onpointerdown={(e: PointerEvent) => { - if (context.editingTaskId === NoTaskId) { + if (context.canStartTaskDragging(taskId)) { context.startTaskDragging(e, taskId); } e.stopPropagation(); @@ -152,7 +152,7 @@ role="presentation" tabindex="-1" > - + {#if !context.taskDraggingManager.isDragging && !context.isReparentingOn() && !(context.chosenBlockedId !== NoTaskId) diff --git a/src/components/TaskText.svelte b/src/components/TaskText.svelte index 0fc8dd3..80fa843 100644 --- a/src/components/TaskText.svelte +++ b/src/components/TaskText.svelte @@ -11,16 +11,15 @@ taskPathFromFile } from "../LinkManager"; import {NoTaskId} from "../NodePositionsCalculator"; + import {TASK_SIZE} from "../Constants"; let { taskId, isUnselected, - isHovered, context, }: { taskId: number, isUnselected: boolean, - isHovered: boolean, context: Context, } = $props(); @@ -34,44 +33,6 @@ let component = new Component(); // Required by Obsidian to manage render lifecycle let isDragging = $derived(context.taskDraggingManager.isDragging); - // Delayed collapse so the max-height animation is visible before text re-clamps - const COLLAPSE_DELAY_MS = 200; - let isTextExpanded = $state(false); - let collapseTimer: ReturnType | undefined; - $effect(() => { - if ((isHovered || isSelected) || isEditing) { - clearTimeout(collapseTimer); - collapseTimer = undefined; - isTextExpanded = true; - } else if (isTextExpanded) { - collapseTimer = setTimeout(() => { - isTextExpanded = false; - collapseTimer = undefined; - }, COLLAPSE_DELAY_MS); - } - }); - // Measure the actual rendered text height after DOM settles and report to the layout engine - $effect(() => { - const active = (isHovered || isSelected) || isEditing; - if (!active) { - context.clearTaskHeightOverride(taskId); - return; - } - // Don't re-measure when switching to edit mode: textPreviewEl is unmounted at that - // point so offsetHeight would be stale/zero, causing a spurious resize. - // The override set during hover/selection remains valid while editing. - if (isEditing) return; - tick().then(() => { - if (!textPreviewEl || isEditing) return; - const taskPaddingPx = 20; // padding: 10px 0 on .task - const containerPaddingPx = 16; // padding: 8px 0 on .task-text-container.expanded - context.setTaskHeightOverride( - taskId, - Math.max(80, 2 * textPreviewEl.offsetHeight + taskPaddingPx + containerPaddingPx - 35) - ); - }); - }); - onMount(() => { if(!isEditing && textPreviewEl) { renderMarkdown(); @@ -94,6 +55,27 @@ // Cleanup function for when component is unmounted return () => document.body.classList.remove('is-dragging-task'); }); + + // Measure the actual rendered text height after DOM settles and report to the layout engine + $effect(() => { + // Don't re-measure when editing: textPreviewEl is unmounted at that point so + // offsetHeight would be stale/zero, causing a spurious resize. + // The override set before editing remains valid while editing. + if (isEditing) return; + tick().then(() => { + if (!textPreviewEl || isEditing) return; + const taskEl = textPreviewEl.closest('.task') as HTMLElement | null; + if (!taskEl) return; + 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); + } + }); + }); function handlePreviewClick(e: PointerEvent) { if (isDragging) { @@ -175,10 +157,11 @@ if (!el) { return; } - if (e.key === "Enter") { - e.preventDefault(); - el.blur(); // Triggers handleBlur - } else if (e.key === "Tab" && suggest !== null) { + // if (e.key === "Enter") { + // e.preventDefault(); + // el.blur(); // Triggers handleBlur + // } else + if (e.key === "Tab" && suggest !== null) { // another hack to select suggest on tab e.preventDefault(); el.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); @@ -232,10 +215,9 @@
@@ -250,9 +232,8 @@ >{taskData.path ? linkFromFilePath(taskData.path) : taskData.name} {:else}
diff --git a/src/components/TaskmapContainer.svelte b/src/components/TaskmapContainer.svelte index 1fe2791..b67dde4 100644 --- a/src/components/TaskmapContainer.svelte +++ b/src/components/TaskmapContainer.svelte @@ -170,20 +170,20 @@ - {#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)} - - {/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)} + + {/each} + {/key} @@ -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)} {/each} diff --git a/src/data/Action.ts b/src/data/Action.ts index faadcdc..f243a66 100644 --- a/src/data/Action.ts +++ b/src/data/Action.ts @@ -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) { diff --git a/src/data/ProjectData.svelte.ts b/src/data/ProjectData.svelte.ts index 9a689b6..4308b00 100644 --- a/src/data/ProjectData.svelte.ts +++ b/src/data/ProjectData.svelte.ts @@ -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(); ancestorsCache = new SvelteMap(); descendantsCache = new SvelteMap(); - tasksVersion: number; + tasksViewUpdateCounter: number; + connectionsViewUpdateCounter: number; blockerPairs: Array; folderPath: string | undefined; curTaskId = RootTaskId; + taskSizeOverrides: SvelteMap; public static getDefault(): ProjectData { return new ProjectData({ @@ -30,26 +33,44 @@ export class ProjectData { blockerPairs: new Array(), 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) { 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() { @@ -117,7 +138,7 @@ export class ProjectData { public addTask(task: TaskData) { this.tasks.push(task); this.rebuildCaches(); - this.markTasksUpdated(); + this.updateTasksView(); this.curTaskId++; } @@ -125,7 +146,7 @@ export class ProjectData { const task = this.tasks.pop(); if (task) { this.rebuildCaches(); - this.markTasksUpdated(); + this.updateTasksView(); this.curTaskId--; } } @@ -294,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; }; diff --git a/src/data/ProjectDataSchema.ts b/src/data/ProjectDataSchema.ts index 0fe5dba..e002bdd 100644 --- a/src/data/ProjectDataSchema.ts +++ b/src/data/ProjectDataSchema.ts @@ -3,7 +3,7 @@ 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). */ -export const TASKMAP_FILE_SCHEMA_VERSION = 2 as const; +export const TASKMAP_FILE_SCHEMA_VERSION = 3 as const; const statusCodeSchema = v.picklist([ StatusCode.DRAFT, @@ -29,6 +29,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( @@ -42,6 +50,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 = { @@ -50,6 +59,7 @@ export type ProjectFileParsed = { blockerPairs: BlockerPair[]; folderPath: string | undefined; curTaskId: number; + taskSizeOverrides: TaskSizeOverrideEntry[]; }; export class TaskmapDataError extends Error { @@ -95,5 +105,6 @@ export function parseProjectFileJson(parsed: unknown): ProjectFileParsed { blockerPairs: o.blockerPairs ?? [], folderPath: o.folderPath, curTaskId: o.curTaskId, + taskSizeOverrides: o.taskSizeOverrides ?? [], }; } diff --git a/src/data/VersionedData.ts b/src/data/VersionedData.ts index 924d2ae..a2768e9 100644 --- a/src/data/VersionedData.ts +++ b/src/data/VersionedData.ts @@ -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 => { + 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) => {