mirror of
https://github.com/poanse/obsidian-taskmap.git
synced 2026-07-22 06:05:58 +00:00
fixes, schema and position stopped resetting
This commit is contained in:
parent
e005c765ba
commit
594cf50b10
19 changed files with 684 additions and 614 deletions
|
|
@ -63,6 +63,7 @@ const context = await esbuild.context({
|
|||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
pure: prod ? ["console.debug"] : [],
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ export default defineConfig([
|
|||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs["flat/recommended"],
|
||||
prettier,
|
||||
...svelte.configs["flat/prettier"],
|
||||
prettier,
|
||||
{
|
||||
files: ["**/*.ts", "**/*.svelte"],
|
||||
languageOptions: {
|
||||
|
|
|
|||
741
package-lock.json
generated
741
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -15,7 +15,6 @@
|
|||
"devDependencies": {
|
||||
"@eslint/css": "^0.14.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "latest",
|
||||
"@typescript-eslint/eslint-plugin": "latest",
|
||||
"@typescript-eslint/parser": "latest",
|
||||
|
|
@ -23,10 +22,12 @@
|
|||
"esbuild": "latest",
|
||||
"esbuild-svelte": "^0.9.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"globals": "^16.5.0",
|
||||
"obsidian": "latest",
|
||||
"prettier": "^3.8.2",
|
||||
"rollup-plugin-svelte": "^7.2.3",
|
||||
"svelte": "^5.45.5",
|
||||
"svelte-check": "^4.3.4",
|
||||
|
|
@ -38,16 +39,11 @@
|
|||
"dependencies": {
|
||||
"@napolab/alpha-blend": "^2.0.0",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"autobind-decorator": "^2.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"esm-env": "^1.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"pixi.js": "^8.14.3",
|
||||
"prettier": "^3.7.4",
|
||||
"rollup-plugin-css-only": "^4.5.5",
|
||||
"svelte-canvas": "^2.1.0",
|
||||
"ts-pattern": "^5.9.0"
|
||||
"ts-pattern": "^5.9.0",
|
||||
"valibot": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,14 @@ import {
|
|||
} from "./NodePositionsCalculator";
|
||||
import { DraggingManager } from "./DraggingManager.svelte";
|
||||
import { delink, LinkManager, tasknameFromFilePath } from "./LinkManager";
|
||||
import { HistoryManager } from "./data/HistoryManager.svelte";
|
||||
import type { ProjectData } from "./data/ProjectData.svelte.js";
|
||||
import { VersionedData } from "./data/VersionedData";
|
||||
import { innerHeight } from "svelte/reactivity/window";
|
||||
import { TASK_SIZE } from "./Constants";
|
||||
|
||||
const DEFAULT_TOOLBAR_STATUS = StatusCode.DRAFT;
|
||||
|
||||
export class Context {
|
||||
app: App;
|
||||
plugin: TaskmapPlugin;
|
||||
|
|
@ -41,9 +45,9 @@ export class Context {
|
|||
chosenBlockedId = $state(NoTaskId);
|
||||
hoveredBlockerId = $state(NoTaskId);
|
||||
hoveredBlockedId = $state(NoTaskId);
|
||||
toolbarStatus = $state(StatusCode.DRAFT);
|
||||
toolbarStatus = $state(DEFAULT_TOOLBAR_STATUS);
|
||||
reparentingTaskId = $state(NoTaskId);
|
||||
versionedData: VersionedData;
|
||||
versionedData = $state(null! as VersionedData);
|
||||
positions: Map<TaskId, Vector2>;
|
||||
taskPositions: Array<{
|
||||
taskId: TaskId;
|
||||
|
|
@ -79,6 +83,47 @@ export class Context {
|
|||
this.updateTaskPositions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces project data from disk (e.g. after an external update) without remounting the view,
|
||||
* so pan/zoom and the scene stay intact.
|
||||
*/
|
||||
public reloadFromDisk(projectData: ProjectData): void {
|
||||
this.versionedData = new VersionedData(projectData, new HistoryManager());
|
||||
this.taskDraggingManager.reset();
|
||||
this.draggedTaskId = NoTaskId;
|
||||
this.reparentingTaskId = NoTaskId;
|
||||
this.chosenBlockerId = NoTaskId;
|
||||
this.chosenBlockedId = NoTaskId;
|
||||
this.hoveredBlockerId = NoTaskId;
|
||||
this.hoveredBlockedId = NoTaskId;
|
||||
this.pressedButtonCode = -1;
|
||||
|
||||
const sel = this.versionedData.getTaskOption(this.selectedTaskId);
|
||||
if (sel == null || sel.deleted) {
|
||||
this.selectedTaskId = NoTaskId;
|
||||
this.toolbarStatus = DEFAULT_TOOLBAR_STATUS;
|
||||
} else {
|
||||
this.toolbarStatus = sel.status;
|
||||
}
|
||||
|
||||
const focused = this.versionedData.getTaskOption(this.focusedTaskId);
|
||||
if (focused == null || focused.deleted) {
|
||||
this.focusedTaskId = RootTaskId;
|
||||
}
|
||||
|
||||
const editing = this.versionedData.getTaskOption(this.editingTaskId);
|
||||
if (editing == null || editing.deleted) {
|
||||
this.editingTaskId = NoTaskId;
|
||||
}
|
||||
|
||||
this.taskPositions = this.versionedData.getTasks().map((task) => ({
|
||||
taskId: task.taskId,
|
||||
tween: null,
|
||||
}));
|
||||
this.updateTaskPositions();
|
||||
this.incrementUpdateOnZoomCounter();
|
||||
}
|
||||
|
||||
public isBlockerHighlighted = (taskId: TaskId) => {
|
||||
if (this.versionedData.getTask(taskId).status === StatusCode.DONE) {
|
||||
return false;
|
||||
|
|
@ -174,7 +219,7 @@ export class Context {
|
|||
blockerPair.blocked,
|
||||
blockerPair.blocker,
|
||||
) ||
|
||||
this.versionedData.isDescendentOf(
|
||||
this.versionedData.isDescendantOf(
|
||||
blockerPair.blocked,
|
||||
blockerPair.blocker,
|
||||
)
|
||||
|
|
@ -203,7 +248,7 @@ export class Context {
|
|||
candidate != task.parentId &&
|
||||
!this.versionedData
|
||||
.getDescendantIds(this.reparentingTaskId)
|
||||
.contains(candidate)
|
||||
.includes(candidate)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +274,7 @@ export class Context {
|
|||
return this.versionedData
|
||||
.getAncestors(this.focusedTaskId)
|
||||
.map((t) => t.taskId)
|
||||
.contains(taskId);
|
||||
.includes(taskId);
|
||||
}
|
||||
|
||||
public updateTaskPositions(draggingOnly = false) {
|
||||
|
|
@ -300,7 +345,7 @@ export class Context {
|
|||
this.draggedTaskId,
|
||||
);
|
||||
this.taskPositions
|
||||
.filter((t) => draggedTaskIds.contains(t.taskId))
|
||||
.filter((t) => draggedTaskIds.includes(t.taskId))
|
||||
.forEach((t) => {
|
||||
if (
|
||||
t.tween !== null &&
|
||||
|
|
@ -334,7 +379,7 @@ export class Context {
|
|||
.filter((t) =>
|
||||
this.versionedData
|
||||
.getChildren(draggedParentId)
|
||||
.contains(t.taskId),
|
||||
.includes(t.taskId),
|
||||
)
|
||||
.sort((a, b) => a.tween?.target.y! - b.tween?.target.y!)
|
||||
.map((t) => t.taskId);
|
||||
|
|
@ -424,8 +469,8 @@ export class Context {
|
|||
ancestorTasks.some((t) => t.hidden) ||
|
||||
!(
|
||||
task.taskId == this.focusedTaskId ||
|
||||
focusedAncestorIds.contains(task.taskId) ||
|
||||
focusedDescendantIds.contains(task.taskId)
|
||||
focusedAncestorIds.includes(task.taskId) ||
|
||||
focusedDescendantIds.includes(task.taskId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export class DraggingManager {
|
|||
public onPointerDown = (e: PointerEvent) => {
|
||||
console.debug("DraggingManager pointerDown");
|
||||
this.mouseDown = e.button as MouseDown;
|
||||
if (this.mouseCodes.contains(this.mouseDown)) {
|
||||
if (this.mouseCodes.includes(this.mouseDown)) {
|
||||
this.startX = e.clientX;
|
||||
this.startY = e.clientY;
|
||||
const target = e.target as HTMLElement;
|
||||
|
|
@ -29,14 +29,19 @@ export class DraggingManager {
|
|||
|
||||
public onPointerUp = (e: PointerEvent) => {
|
||||
console.debug(`DraggingManager pointerUp: ${this.deltaX}`);
|
||||
this.reset();
|
||||
};
|
||||
|
||||
/** Clears drag state without requiring a pointer event (e.g. external file reload). */
|
||||
reset(): void {
|
||||
this.mouseDown = MouseDown.NONE;
|
||||
this.isDragging = false;
|
||||
this.deltaX = 0;
|
||||
this.deltaY = 0;
|
||||
};
|
||||
}
|
||||
|
||||
public onPointerMove = (e: PointerEvent) => {
|
||||
if (this.mouseCodes.contains(this.mouseDown)) {
|
||||
if (this.mouseCodes.includes(this.mouseDown)) {
|
||||
const deltaX = e.clientX - this.startX;
|
||||
const deltaY = e.clientY - this.startY;
|
||||
// Calculate distance moved
|
||||
|
|
|
|||
96
src/FileWatcher.ts
Normal file
96
src/FileWatcher.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Notice, TAbstractFile, type App, TFile, TFolder } from "obsidian";
|
||||
import type { ProjectData } from "./data/ProjectData.svelte";
|
||||
import type { TaskData } from "./types";
|
||||
import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView";
|
||||
import { deserializeProjectData, updateFile } from "./SaveManager";
|
||||
import { generateMarkdownLink } from "./LinkManager";
|
||||
|
||||
|
||||
export function getOnRename(app: App) {
|
||||
return async (file: TAbstractFile, oldPath: string) => {
|
||||
console.debug(`${file.path} renamed`);
|
||||
let files: TFile[];
|
||||
if (file instanceof TFile) {
|
||||
files = [file];
|
||||
} else if (file instanceof TFolder) {
|
||||
files = app.vault
|
||||
.getMarkdownFiles()
|
||||
.filter((mdFile) =>
|
||||
mdFile.path.startsWith(file.path + "/"),
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await handleRenameFiles(
|
||||
app,
|
||||
files,
|
||||
oldPath,
|
||||
file.path,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleRenameFiles(
|
||||
app: App,
|
||||
changedMdFiles: TFile[],
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
) {
|
||||
const taskmapFiles = app.vault
|
||||
.getFiles()
|
||||
.filter((file) => file.path.endsWith(".taskmap"));
|
||||
|
||||
const updatedTaskMapFilePaths: Set<string> = new Set<string>();
|
||||
|
||||
// update paths in taskmap files
|
||||
for (const taskmapFile of taskmapFiles) {
|
||||
console.debug(`handling ${taskmapFile.path}`);
|
||||
let projectData: ProjectData;
|
||||
try {
|
||||
const projectDataRaw = await app.vault.read(taskmapFile);
|
||||
projectData = deserializeProjectData(projectDataRaw);
|
||||
} catch (e) {
|
||||
console.error(`Taskmap rename hook: skipped invalid file ${taskmapFile.path}`);
|
||||
new Notice(`Taskmap rename hook: skipped invalid file: ${String(e)}`);
|
||||
continue;
|
||||
}
|
||||
const mapping = new Map<string, TaskData>();
|
||||
projectData.tasks.forEach((t) => {
|
||||
if (t.path) {
|
||||
if (t.path.startsWith(oldPath)) {
|
||||
t.path = newPath + t.path.slice(oldPath.length);
|
||||
}
|
||||
mapping.set(t.path, t);
|
||||
}
|
||||
});
|
||||
console.debug("mapping " + JSON.stringify(mapping.keys()));
|
||||
let changed = false;
|
||||
changedMdFiles.forEach((mdFile) => {
|
||||
if (mapping.has(mdFile.path)) {
|
||||
const t = mapping.get(mdFile.path)!;
|
||||
t.name = generateMarkdownLink(app, mdFile);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
console.debug(`${taskmapFile.path} changed`);
|
||||
// resave file on the file system
|
||||
await updateFile(app, taskmapFile, projectData);
|
||||
updatedTaskMapFilePaths.add(taskmapFile.path);
|
||||
} else {
|
||||
console.debug(`${taskmapFile.path} not changed`);
|
||||
}
|
||||
}
|
||||
|
||||
// refresh active views
|
||||
for (const view of app.workspace
|
||||
.getLeavesOfType(TASKMAP_VIEW_TYPE)
|
||||
.map((l) => l.view)
|
||||
.filter((view) => view instanceof TaskmapView)
|
||||
.filter(
|
||||
(view) =>
|
||||
view.file && updatedTaskMapFilePaths.has(view.file.path),
|
||||
)) {
|
||||
await view.refreshUi();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
import { ProjectData } from "./data/ProjectData.svelte.js";
|
||||
import type { App, TFile } from "obsidian";
|
||||
import { ProjectData } from "./data/ProjectData.svelte";
|
||||
import { type App, Notice, type TFile } from "obsidian";
|
||||
import {
|
||||
parseProjectFileJson,
|
||||
TaskmapDataError,
|
||||
TASKMAP_FILE_SCHEMA_VERSION,
|
||||
} from "./data/ProjectDataSchema";
|
||||
|
||||
export { TaskmapDataError };
|
||||
|
||||
export function serializeProjectData(projectData: ProjectData) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
schemaVersion: TASKMAP_FILE_SCHEMA_VERSION,
|
||||
tasks: projectData.tasks,
|
||||
blockerPairs: projectData.blockerPairs,
|
||||
curTaskId: projectData.curTaskId,
|
||||
|
|
@ -13,8 +21,18 @@ export function serializeProjectData(projectData: ProjectData) {
|
|||
);
|
||||
}
|
||||
|
||||
export function deserializeProjectData(app: App, data: string) {
|
||||
return new ProjectData(JSON.parse(data));
|
||||
export function deserializeProjectData(data: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch (err) {
|
||||
new Notice(`Taskmap file could not be read: ${String(err)}`);
|
||||
throw new TaskmapDataError(
|
||||
`Taskmap file is not valid JSON. ${String(err)}`,
|
||||
);
|
||||
}
|
||||
const input = parseProjectFileJson(parsed);
|
||||
return new ProjectData(input);
|
||||
}
|
||||
|
||||
export async function updateFile(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { debounce, TextFileView, TFile, WorkspaceLeaf } from "obsidian";
|
||||
import { mount, unmount } from "svelte";
|
||||
import { DEFAULT_DATA, ProjectData } from "./data/ProjectData.svelte.js";
|
||||
import { DEFAULT_DATA, ProjectData } from "./data/ProjectData.svelte";
|
||||
import { Context } from "./Context.svelte.js";
|
||||
import { NodePositionsCalculator } from "./NodePositionsCalculator";
|
||||
import TaskmapContainer from "./components/TaskmapContainer.svelte";
|
||||
|
|
@ -32,15 +32,21 @@ export class TaskmapView extends TextFileView {
|
|||
async refreshUi() {
|
||||
const projectFile = this.file!;
|
||||
await this.debouncedSave.run();
|
||||
this.clear();
|
||||
await this.onLoadFile(projectFile);
|
||||
if (this.taskmapContainer === undefined) {
|
||||
await this.onLoadFile(projectFile);
|
||||
} else {
|
||||
const data = await this.app.vault.read(projectFile);
|
||||
this.setViewData(data);
|
||||
this.projectData = deserializeProjectData(data);
|
||||
this.context.reloadFromDisk(this.projectData);
|
||||
}
|
||||
}
|
||||
|
||||
async onLoadFile(file: TFile): Promise<void> {
|
||||
this.file = file;
|
||||
const data = await this.app.vault.read(file);
|
||||
this.setViewData(data);
|
||||
this.projectData = deserializeProjectData(this.app, this.data);
|
||||
this.projectData = deserializeProjectData(data);
|
||||
this.context = new Context(
|
||||
this.app,
|
||||
this.plugin,
|
||||
|
|
@ -56,22 +62,6 @@ export class TaskmapView extends TextFileView {
|
|||
});
|
||||
}
|
||||
|
||||
getFile() {
|
||||
if (this.file) {
|
||||
return this.file;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
getFilePath(): string {
|
||||
if (this.file) {
|
||||
return this.file.path;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
getViewData(): string {
|
||||
return this.data;
|
||||
}
|
||||
|
|
@ -88,17 +78,16 @@ export class TaskmapView extends TextFileView {
|
|||
try {
|
||||
await updateFile(this.app, this.file!, this.projectData);
|
||||
} catch (err) {
|
||||
console.error("Save failed:", err);
|
||||
throw new Error(`Save failed. ${err.message}`);
|
||||
console.error("Save failed: ", err);
|
||||
throw new Error(`Save failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.debouncedSave.cancel();
|
||||
this.setViewData(DEFAULT_DATA);
|
||||
// 3. Proper unmounting in the clear cycle
|
||||
if (this.taskmapContainer) {
|
||||
unmount(this.taskmapContainer);
|
||||
void unmount(this.taskmapContainer);
|
||||
this.taskmapContainer = undefined;
|
||||
}
|
||||
this.contentEl.empty();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@
|
|||
>
|
||||
{#if entered}
|
||||
<svg
|
||||
role="button"
|
||||
aria-label="Add task"
|
||||
tabindex="0"
|
||||
class="button-add"
|
||||
class:draft={taskData.status === StatusCode.DRAFT}
|
||||
class:ready={taskData.status === StatusCode.READY}
|
||||
|
|
@ -57,29 +60,16 @@
|
|||
background: transparent;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
|
||||
.button-add {
|
||||
width: 41px;
|
||||
height: 41px;
|
||||
stroke-width: 1;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
}
|
||||
:global(svg.draft) {
|
||||
stroke: #7E7E7E;
|
||||
fill: #1E1E1E;
|
||||
}
|
||||
:global(svg.ready) {
|
||||
stroke: #A1383D;
|
||||
fill: #2E2122;
|
||||
}
|
||||
:global(svg.in-progress) {
|
||||
stroke: #A6A45D;
|
||||
fill: #2C2C24;
|
||||
}
|
||||
:global(svg.done) {
|
||||
stroke: #3E9959;
|
||||
fill: #212B24;
|
||||
fill: var(--button-fill, none);
|
||||
stroke: var(--button-stroke, currentColor);
|
||||
}
|
||||
.button-add.draft { --button-stroke: #7E7E7E; --button-fill: #1E1E1E; }
|
||||
.button-add.ready { --button-stroke: #A1383D; --button-fill: #2E2122; }
|
||||
.button-add.in-progress { --button-stroke: #A6A45D; --button-fill: #2C2C24; }
|
||||
.button-add.done { --button-stroke: #3E9959; --button-fill: #212B24; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
IconCode.KEY,
|
||||
IconCode.LOCK,
|
||||
IconCode.REPARENT
|
||||
].contains(iconCode);
|
||||
].includes(iconCode);
|
||||
|
||||
function onpointerdown(event: MouseEvent) {
|
||||
isPressedDown = true;
|
||||
|
|
@ -44,8 +44,8 @@
|
|||
}
|
||||
|
||||
let isButtonDisabled = $derived(
|
||||
[IconCode.LOCK, IconCode.KEY].contains(iconCode) && context.isReparentingOn()
|
||||
|| [IconCode.LOCK, IconCode.KEY].contains(iconCode) && context.versionedData.getTask(context.selectedTaskId).status == StatusCode.DONE
|
||||
[IconCode.LOCK, IconCode.KEY].includes(iconCode) && context.isReparentingOn()
|
||||
|| [IconCode.LOCK, IconCode.KEY].includes(iconCode) && context.versionedData.getTask(context.selectedTaskId).status == StatusCode.DONE
|
||||
|| (iconCode == IconCode.REPARENT && context.chosenBlockerId !== NoTaskId)
|
||||
|| (iconCode == IconCode.REPARENT && context.chosenBlockedId !== NoTaskId)
|
||||
|| (iconCode === IconCode.STATUS_DONE && context.versionedData.isTaskBlocked(context.selectedTaskId))
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
width: 41px;
|
||||
height: 41px;
|
||||
stroke: #bbb;
|
||||
fill: #181818;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import {delink, getFromRelativePath, isLink} from "../LinkManager";
|
||||
import {NoTaskId} from "../NodePositionsCalculator";
|
||||
|
||||
// PROPS
|
||||
let {
|
||||
taskId,
|
||||
isUnselected,
|
||||
|
|
@ -18,7 +17,7 @@
|
|||
} = $props();
|
||||
|
||||
// STATE
|
||||
let taskData = context.versionedData.getTask(taskId);
|
||||
let taskData = $derived(context.versionedData.getTask(taskId));
|
||||
let isSelected = $derived(context.isSelected(taskId));
|
||||
let isEditing = $derived(context.editingTaskId === taskId);
|
||||
let suggest: LinkSuggest | null = null;
|
||||
|
|
@ -87,7 +86,7 @@
|
|||
hoverParent: textPreviewEl,
|
||||
targetEl: link,
|
||||
linktext: link.getAttribute("data-href"),
|
||||
sourcePath: ""
|
||||
sourcePath: taskData.path ?? ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -192,13 +191,11 @@
|
|||
class="text-preview tasktext"
|
||||
class:unselect={isUnselected}
|
||||
bind:this={textPreviewEl}
|
||||
onpointerup={handlePreviewClick}
|
||||
onmouseover={handlePreviewMouseOver}
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!--onkeydown={(e) => e.key === 'Enter' && toggleEdit()}-->
|
||||
|
||||
<style>
|
||||
:root, .task-text-container {
|
||||
|
|
@ -218,6 +215,12 @@
|
|||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
.task-text-container.selected .tasktext:hover {
|
||||
cursor: text;
|
||||
}
|
||||
.task-text-container.not-selected .tasktext:hover {
|
||||
/*cursor: default;*/
|
||||
}
|
||||
.tasktext {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -248,17 +251,11 @@
|
|||
.tasktext.unselect {
|
||||
color: color-mix(in srgb, #7E7E7E 100%, #000000 50%);
|
||||
}
|
||||
.task-text-container.selected .tasktext:hover {
|
||||
cursor: text;
|
||||
}
|
||||
.task-text-container.not-selected .tasktext:hover {
|
||||
/*cursor: default;*/
|
||||
}
|
||||
|
||||
.text-edit {
|
||||
display: flex;
|
||||
field-sizing: content;
|
||||
padding-top: 4px; /* fixes text jumping between text-edit and text-preview */
|
||||
padding-top: 5px; /* fixes text jumping between text-edit and text-preview */
|
||||
}
|
||||
.text-edit:hover {
|
||||
background-color: transparent;
|
||||
|
|
@ -284,8 +281,6 @@
|
|||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/*white-space: pre-wrap;*/
|
||||
/*word-wrap: break-word;*/
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
let viewportEl: HTMLDivElement | null = null;
|
||||
let sceneEl: HTMLDivElement | null = null;
|
||||
let svgGroupEl: SVGGElement | null = null;
|
||||
let panzoom: PanzoomObject | null = null;
|
||||
let panstart: Vector2 = {x: 0, y: 0};
|
||||
let draggingManager = new DraggingManager([MouseDown.MIDDLE, MouseDown.LEFT]);
|
||||
|
|
@ -24,9 +23,6 @@
|
|||
if (!sceneEl) {
|
||||
throw new Error('No scene element');
|
||||
}
|
||||
if (!svgGroupEl) {
|
||||
throw new Error('No svg group element');
|
||||
}
|
||||
if (!viewportEl) {
|
||||
throw new Error('No viewport');
|
||||
}
|
||||
|
|
@ -168,13 +164,11 @@
|
|||
>
|
||||
<svg class="svg-layer" overflow="visible">
|
||||
<defs>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="5" markerHeight="10" refX="3" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M-1,0 L-1,6 L4.5,3 z" fill="context-stroke" />
|
||||
</marker>
|
||||
</defs>
|
||||
<marker id="arrow" markerWidth="5" markerHeight="10" refX="3" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M-1,0 L-1,6 L4.5,3 z" fill="context-stroke" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g class="svg-group" bind:this={svgGroupEl}>
|
||||
<g class="svg-group">
|
||||
{#each (context.versionedData.getTasks()
|
||||
.filter(t => !context.isTaskHidden(t.taskId))
|
||||
.filter(t => t.taskId !== RootTaskId)
|
||||
|
|
@ -201,13 +195,11 @@
|
|||
|
||||
<svg class="svg-layer" overflow="visible">
|
||||
<defs>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="5" markerHeight="10" refX="3" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M-1,0 L-1,6 L4.5,3 z" fill="context-stroke" />
|
||||
</marker>
|
||||
</defs>
|
||||
<marker id="arrow" markerWidth="5" markerHeight="10" refX="3" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M-1,0 L-1,6 L4.5,3 z" fill="context-stroke" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g class="svg-group" bind:this={svgGroupEl}>
|
||||
<g class="svg-group">
|
||||
{#if context.chosenBlockerId !== NoTaskId || context.chosenBlockedId !== NoTaskId || context.hoveredBlockerId !== NoTaskId || context.hoveredBlockedId !== NoTaskId}
|
||||
{#each (context.versionedData.getBlockerPairs().filter(
|
||||
p => {
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@ export class ProjectData {
|
|||
public isAncestorOf(taskId: TaskId, candidate: TaskId) {
|
||||
return this.getAncestors(taskId)
|
||||
.map((t) => t.taskId)
|
||||
.contains(candidate);
|
||||
.includes(candidate);
|
||||
}
|
||||
|
||||
public isDescendentOf(taskId: TaskId, candidate: TaskId) {
|
||||
return this.getDescendantIds(taskId).contains(candidate);
|
||||
public isDescendantOf(taskId: TaskId, candidate: TaskId) {
|
||||
return this.getDescendantIds(taskId).includes(candidate);
|
||||
}
|
||||
|
||||
public getChildren(taskId: number, includeDeleted: boolean = false) {
|
||||
|
|
|
|||
94
src/data/ProjectDataSchema.ts
Normal file
94
src/data/ProjectDataSchema.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
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). */
|
||||
export const TASKMAP_FILE_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
const statusCodeSchema = v.picklist([
|
||||
StatusCode.DRAFT,
|
||||
StatusCode.READY,
|
||||
StatusCode.IN_PROGRESS,
|
||||
StatusCode.DONE,
|
||||
] as const);
|
||||
|
||||
const taskDataSchema = v.object({
|
||||
name: v.string(),
|
||||
path: v.optional(v.string()),
|
||||
taskId: v.pipe(v.number(), v.integer()),
|
||||
status: statusCodeSchema,
|
||||
deleted: v.boolean(),
|
||||
parentId: v.pipe(v.number(), v.integer()),
|
||||
depth: v.pipe(v.number(), v.integer()),
|
||||
priority: v.pipe(v.number(), v.integer()),
|
||||
hidden: v.boolean(),
|
||||
});
|
||||
|
||||
const blockerPairSchema = v.object({
|
||||
blocker: v.pipe(v.number(), v.integer()),
|
||||
blocked: v.pipe(v.number(), v.integer()),
|
||||
});
|
||||
|
||||
export const projectFileSchema = v.object({
|
||||
schemaVersion: v.optional(
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(1),
|
||||
v.maxValue(TASKMAP_FILE_SCHEMA_VERSION),
|
||||
),
|
||||
),
|
||||
tasks: v.array(taskDataSchema),
|
||||
blockerPairs: v.optional(v.array(blockerPairSchema)),
|
||||
curTaskId: v.pipe(v.number(), v.integer()),
|
||||
});
|
||||
|
||||
export type ProjectFileParsed = {
|
||||
tasks: TaskData[];
|
||||
blockerPairs: BlockerPair[];
|
||||
curTaskId: number;
|
||||
};
|
||||
|
||||
export class TaskmapDataError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TaskmapDataError";
|
||||
}
|
||||
}
|
||||
|
||||
function formatFlattenedIssues(
|
||||
flat: FlatErrors<typeof projectFileSchema>,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (flat.root?.length) {
|
||||
parts.push(...flat.root);
|
||||
}
|
||||
if (flat.nested) {
|
||||
for (const [path, msgs] of Object.entries(flat.nested)) {
|
||||
if (msgs?.length) {
|
||||
parts.push(`${path}: ${msgs.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (flat.other?.length) {
|
||||
parts.push(...flat.other);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export function parseProjectFileJson(parsed: unknown): ProjectFileParsed {
|
||||
const result = v.safeParse(projectFileSchema, parsed);
|
||||
if (!result.success) {
|
||||
const flat = v.flatten(result.issues);
|
||||
const detail = formatFlattenedIssues(flat);
|
||||
throw new TaskmapDataError(
|
||||
detail || "Taskmap file does not match the expected structure.",
|
||||
);
|
||||
}
|
||||
const o = result.output;
|
||||
return {
|
||||
tasks: o.tasks,
|
||||
blockerPairs: o.blockerPairs ?? [],
|
||||
curTaskId: o.curTaskId,
|
||||
};
|
||||
}
|
||||
|
|
@ -152,8 +152,8 @@ export class VersionedData {
|
|||
return this.data.isAncestorOf(taskId, candidate);
|
||||
};
|
||||
|
||||
public isDescendentOf = (taskId: TaskId, candidate: TaskId) => {
|
||||
return this.data.isDescendentOf(taskId, candidate);
|
||||
public isDescendantOf = (taskId: TaskId, candidate: TaskId) => {
|
||||
return this.data.isDescendantOf(taskId, candidate);
|
||||
};
|
||||
|
||||
public containsBlockerPair = (blockerPair: BlockerPair) => {
|
||||
|
|
|
|||
120
src/main.ts
120
src/main.ts
|
|
@ -1,10 +1,6 @@
|
|||
import {
|
||||
addIcon,
|
||||
type App,
|
||||
Plugin,
|
||||
TAbstractFile,
|
||||
TFile,
|
||||
TFolder,
|
||||
} from "obsidian";
|
||||
import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView";
|
||||
import {
|
||||
|
|
@ -12,11 +8,9 @@ import {
|
|||
type TaskmapPluginSettings,
|
||||
} from "./TaskmapPluginSettings";
|
||||
import { TaskmapSettingTab } from "./TaskmapSettingTab";
|
||||
import { DEFAULT_DATA } from "./data/ProjectData.svelte.js";
|
||||
import { DEFAULT_DATA} from "./data/ProjectData.svelte";
|
||||
import { LOGO_CONTENT, LOGO_NAME } from "./IconService";
|
||||
import type { TaskData } from "./types";
|
||||
import { deserializeProjectData, updateFile } from "./SaveManager";
|
||||
import { generateMarkdownLink } from "./LinkManager";
|
||||
import { getOnRename} from "./FileWatcher";
|
||||
|
||||
export const FILE_EXTENSION = "taskmap";
|
||||
|
||||
|
|
@ -31,121 +25,19 @@ export default class TaskmapPlugin extends Plugin {
|
|||
);
|
||||
this.registerExtensions([FILE_EXTENSION], TASKMAP_VIEW_TYPE);
|
||||
|
||||
// This creates an icon in the left ribbon.
|
||||
addIcon(LOGO_NAME, LOGO_CONTENT);
|
||||
this.addRibbonIcon(LOGO_NAME, "New taskmap", (_evt: MouseEvent) => {
|
||||
this.createAndOpenDrawing();
|
||||
void this.createAndOpenDrawing();
|
||||
});
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new TaskmapSettingTab(this.app, this));
|
||||
|
||||
// // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
||||
// // Using this function will automatically remove the event listener when this plugin is disabled.
|
||||
// this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
// console.debug('click', evt);
|
||||
// });
|
||||
|
||||
// // When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
||||
// this.registerInterval(window.setInterval(() => console.debug('setInterval'), 5 * 60 * 1000));
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
async (file: TAbstractFile, oldPath: string) => {
|
||||
console.debug(`${file.path} renamed`);
|
||||
if (file instanceof TFile) {
|
||||
await this.handleRenameFiles(
|
||||
this.app,
|
||||
[file],
|
||||
oldPath,
|
||||
file.path,
|
||||
);
|
||||
} else if (file instanceof TFolder) {
|
||||
const files = this.app.vault
|
||||
.getMarkdownFiles()
|
||||
.filter((file) =>
|
||||
file.path.startsWith(file.path + "/"),
|
||||
);
|
||||
await this.handleRenameFiles(
|
||||
this.app,
|
||||
files,
|
||||
oldPath,
|
||||
file.path,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
this.app.vault.on("rename", getOnRename(this.app)),
|
||||
);
|
||||
}
|
||||
|
||||
async handleRenameFiles(
|
||||
app: App,
|
||||
changedMdFiles: TFile[],
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
) {
|
||||
const taskmapFiles = this.app.vault
|
||||
.getFiles()
|
||||
.filter((file) => file.path.endsWith(".taskmap"));
|
||||
|
||||
const updatedTaskMapFilePaths: Set<string> = new Set<string>();
|
||||
|
||||
// update paths in taskmap files
|
||||
for (const taskmapFile of taskmapFiles) {
|
||||
console.debug(`handling ${taskmapFile.path}`);
|
||||
const projectDataRaw = await this.app.vault.read(taskmapFile);
|
||||
const projectData = deserializeProjectData(
|
||||
this.app,
|
||||
projectDataRaw,
|
||||
);
|
||||
const mapping = new Map<string, TaskData>();
|
||||
projectData.tasks.forEach((t) => {
|
||||
if (t.path) {
|
||||
if (t.path.startsWith(oldPath)) {
|
||||
t.path = t.path.replace(oldPath, newPath);
|
||||
}
|
||||
mapping.set(t.path, t);
|
||||
}
|
||||
});
|
||||
console.debug("mapping " + JSON.stringify(mapping.keys()));
|
||||
let changed = false;
|
||||
changedMdFiles.forEach((mdFile) => {
|
||||
if (mdFile.path.contains("testNote")) {
|
||||
console.debug(`testNode filepath ${mdFile.path}`);
|
||||
}
|
||||
if (mapping.has(mdFile.path)) {
|
||||
const t = mapping.get(mdFile.path)!;
|
||||
t.name = generateMarkdownLink(this.app, mdFile);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
console.debug(`${taskmapFile.path} changed`);
|
||||
// resave file on the file system
|
||||
await updateFile(app, taskmapFile, projectData);
|
||||
updatedTaskMapFilePaths.add(taskmapFile.path);
|
||||
} else {
|
||||
console.debug(`${taskmapFile.path} not changed`);
|
||||
}
|
||||
}
|
||||
|
||||
// refresh active views
|
||||
for (const view of app.workspace
|
||||
.getLeavesOfType(TASKMAP_VIEW_TYPE)
|
||||
.map((l) => l.view)
|
||||
.filter((view) => view instanceof TaskmapView)
|
||||
.filter(
|
||||
(view) =>
|
||||
view.file && updatedTaskMapFilePaths.has(view.file.path),
|
||||
)) {
|
||||
await view.refreshUi();
|
||||
}
|
||||
}
|
||||
|
||||
public async createAndOpenDrawing(): Promise<string> {
|
||||
this.app.workspace.detachLeavesOfType(TASKMAP_VIEW_TYPE);
|
||||
|
||||
const file = await this.app.vault.create(
|
||||
`Example ${window.moment().format("YY-MM-DD hh.mm.ss")}.${FILE_EXTENSION}`,
|
||||
DEFAULT_DATA,
|
||||
|
|
@ -160,9 +52,7 @@ export default class TaskmapPlugin extends Plugin {
|
|||
state: leaf.view.getState(),
|
||||
});
|
||||
|
||||
await this.app.workspace.revealLeaf(
|
||||
this.app.workspace.getLeavesOfType(TASKMAP_VIEW_TYPE)[0],
|
||||
);
|
||||
await this.app.workspace.revealLeaf(leaf);
|
||||
|
||||
return file.path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const isStatusCode = (s: IconCode) => {
|
|||
IconCode.STATUS_DRAFT,
|
||||
IconCode.STATUS_DONE,
|
||||
IconCode.STATUS_IN_PROGRESS,
|
||||
].contains(s);
|
||||
].includes(s);
|
||||
};
|
||||
|
||||
export const classStringFromStatusCode = (code: StatusCode) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue