mirror of
https://github.com/poanse/obsidian-taskmap.git
synced 2026-07-22 14:00:24 +00:00
link update on rename and tooltips
This commit is contained in:
parent
6d2a1739d9
commit
cd5a6f5d5e
14 changed files with 433 additions and 202 deletions
|
|
@ -2,7 +2,7 @@
|
|||
export const TOOLBAR_GAP = 2;
|
||||
export const TOOLBAR_PADDING = { x: 4, y: 4 };
|
||||
export const TOOLBAR_SHIFT = 4;
|
||||
export const SUBTOOLBAR_SHIFT = 2;
|
||||
export const SUBTOOLBAR_SHIFT = 4;
|
||||
|
||||
export const TOOLBAR_SIZE = {
|
||||
width: 98 * 2,
|
||||
|
|
|
|||
|
|
@ -17,12 +17,15 @@ import {
|
|||
V2,
|
||||
} from "./NodePositionsCalculator";
|
||||
import { DraggingManager } from "./DraggingManager.svelte";
|
||||
import { delink, LinkManager, tasknameFromFilePath } from "./LinkManager";
|
||||
|
||||
export class Context {
|
||||
app: App;
|
||||
view: TaskmapView;
|
||||
nodePositionsCalculator: NodePositionsCalculator;
|
||||
linkManager: LinkManager;
|
||||
pressedButtonCode = $state(-1);
|
||||
editingTaskId = $state(NoTaskId);
|
||||
draggedTaskId = $state(NoTaskId);
|
||||
selectedTaskId = $state(NoTaskId);
|
||||
focusedTaskId = $state(RootTaskId);
|
||||
|
|
@ -53,6 +56,7 @@ export class Context {
|
|||
this.nodePositionsCalculator = nodePositionsCalculator;
|
||||
this.app = app;
|
||||
this.projectData = projectData;
|
||||
this.linkManager = new LinkManager(app);
|
||||
|
||||
this.taskPositions = projectData.tasks
|
||||
.filter((t) => !t.deleted)
|
||||
|
|
@ -273,11 +277,9 @@ export class Context {
|
|||
}
|
||||
|
||||
public save() {
|
||||
const x = TaskmapPlugin.getActiveView();
|
||||
if (x != null) {
|
||||
x.debouncedSave();
|
||||
console.log("saved");
|
||||
}
|
||||
const x = this.view;
|
||||
x.debouncedSave();
|
||||
console.log("saved");
|
||||
}
|
||||
|
||||
public addTask(parentId: TaskId): void {
|
||||
|
|
@ -352,7 +354,7 @@ export class Context {
|
|||
`Created by Taskmap.`,
|
||||
);
|
||||
const task = this.projectData.getTask(taskId);
|
||||
task.name = this.tasknameFromFilePath(filepath);
|
||||
task.name = tasknameFromFilePath(filepath);
|
||||
this.save();
|
||||
await this.openOrFocusNote(tfile);
|
||||
} catch (error) {
|
||||
|
|
@ -368,17 +370,10 @@ export class Context {
|
|||
const taskName = task.name;
|
||||
// Sanitize the name for Obsidian filenames
|
||||
let sanitizedName = taskName.replace(/[\\/:*?"<>|]/g, "-");
|
||||
sanitizedName = this.delink(sanitizedName);
|
||||
sanitizedName = delink(sanitizedName);
|
||||
return `${sanitizedName}.md`;
|
||||
}
|
||||
|
||||
public tasknameFromFilePath(path: string) {
|
||||
if (path.endsWith(".md")) {
|
||||
path = path.slice(0, path.length - 3);
|
||||
}
|
||||
return `[[${path}]]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file with "Smart Focus":
|
||||
* 1. If file is already open anywhere -> Focus it.
|
||||
|
|
@ -437,14 +432,6 @@ export class Context {
|
|||
}
|
||||
}
|
||||
|
||||
public isLink(s: string): boolean {
|
||||
return s.startsWith("[[") && s.endsWith("]]");
|
||||
}
|
||||
|
||||
public delink(s: string) {
|
||||
return this.isLink(s) ? s.slice(2, s.length - 2) : s;
|
||||
}
|
||||
|
||||
public serializeForDebugging() {
|
||||
return JSON.stringify({
|
||||
pressedButtonIndex: this.pressedButtonCode,
|
||||
|
|
|
|||
48
src/LinkManager.ts
Normal file
48
src/LinkManager.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { App, TFile } from "obsidian";
|
||||
|
||||
export function tasknameFromFilePath(path: string) {
|
||||
if (path.endsWith(".md")) {
|
||||
path = path.slice(0, path.length - 3);
|
||||
}
|
||||
return `[[${path}]]`;
|
||||
}
|
||||
|
||||
export function isLink(s: string): boolean {
|
||||
return s.startsWith("[[") && s.endsWith("]]");
|
||||
}
|
||||
|
||||
export function delink(s: string) {
|
||||
return isLink(s) ? s.slice(2, s.length - 2) : s;
|
||||
}
|
||||
|
||||
// relativePath to TFile
|
||||
export function getFromRelativePath(app: App, path: string) {
|
||||
return app.vault.getFileByPath(path);
|
||||
}
|
||||
// TFile to wikilink
|
||||
export function generateMarkdownLink(app: App, file: TFile) {
|
||||
return app.fileManager.generateMarkdownLink(file, "");
|
||||
}
|
||||
|
||||
export class LinkManager {
|
||||
private app: App;
|
||||
|
||||
public constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
// wikilink to TFile
|
||||
public getFromLink = (linkText: string) => {
|
||||
return this.app.metadataCache.getFirstLinkpathDest(
|
||||
delink(linkText),
|
||||
"",
|
||||
);
|
||||
};
|
||||
|
||||
public extractTextLink = (text: string) => {
|
||||
if (!isLink(text)) {
|
||||
throw new Error("Text is not a link");
|
||||
}
|
||||
return delink(text);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { StatusCode, type TaskData, type TaskId } from "./types";
|
||||
import { NoTaskId, RootTaskId } from "./NodePositionsCalculator";
|
||||
import { serializeProjectData } from "./SaveManager";
|
||||
|
||||
export class ProjectData {
|
||||
tasks = $state(new Array<TaskData>());
|
||||
|
|
@ -12,14 +13,6 @@ export class ProjectData {
|
|||
});
|
||||
}
|
||||
|
||||
public serialize() {
|
||||
return JSON.stringify(
|
||||
{ tasks: this.tasks, curTaskId: this.curTaskId },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(obj: { tasks: TaskData[]; curTaskId: number }) {
|
||||
this.tasks = obj.tasks;
|
||||
this.curTaskId = obj.curTaskId;
|
||||
|
|
@ -229,4 +222,4 @@ export class ProjectData {
|
|||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_DATA = ProjectData.getDefault().serialize();
|
||||
export const DEFAULT_DATA = serializeProjectData(ProjectData.getDefault());
|
||||
|
|
|
|||
25
src/SaveManager.ts
Normal file
25
src/SaveManager.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { ProjectData } from "./ProjectData.svelte";
|
||||
import type { App, TFile } from "obsidian";
|
||||
|
||||
export function serializeProjectData(projectData: ProjectData) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
tasks: projectData.tasks,
|
||||
curTaskId: projectData.curTaskId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function deserializeProjectData(app: App, data: string) {
|
||||
return new ProjectData(JSON.parse(data));
|
||||
}
|
||||
|
||||
export async function updateFile(
|
||||
app: App,
|
||||
file: TFile,
|
||||
projectData: ProjectData,
|
||||
) {
|
||||
await app.vault.modify(file!, serializeProjectData(projectData));
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@ import { DEFAULT_DATA, ProjectData } from "./ProjectData.svelte";
|
|||
import { Context } from "./Context.svelte.js";
|
||||
import { NodePositionsCalculator } from "./NodePositionsCalculator";
|
||||
import TaskmapContainer from "./components/TaskmapContainer.svelte";
|
||||
import { deserializeProjectData, updateFile } from "./SaveManager";
|
||||
|
||||
export const VIEW_TYPE = "taskmap-view";
|
||||
export const TASKMAP_VIEW_TYPE = "taskmap-view";
|
||||
|
||||
export class TaskmapView extends TextFileView {
|
||||
taskmapContainer: ReturnType<typeof TaskmapContainer> | undefined;
|
||||
|
|
@ -18,24 +19,33 @@ export class TaskmapView extends TextFileView {
|
|||
|
||||
data: string = DEFAULT_DATA;
|
||||
projectData: ProjectData;
|
||||
context: Context;
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE;
|
||||
return TASKMAP_VIEW_TYPE;
|
||||
}
|
||||
|
||||
async refreshUi() {
|
||||
const projectFile = this.file!;
|
||||
this.clear();
|
||||
await this.onLoadFile(projectFile);
|
||||
}
|
||||
|
||||
async onLoadFile(file: TFile): Promise<void> {
|
||||
this.file = file;
|
||||
this.setViewData(await this.app.vault.read(file));
|
||||
this.projectData = new ProjectData(JSON.parse(this.data));
|
||||
const data = await this.app.vault.read(file);
|
||||
this.setViewData(data);
|
||||
this.projectData = deserializeProjectData(this.app, this.data);
|
||||
this.context = new Context(
|
||||
this,
|
||||
this.projectData,
|
||||
this.app,
|
||||
new NodePositionsCalculator(),
|
||||
);
|
||||
this.taskmapContainer = mount(TaskmapContainer, {
|
||||
target: this.contentEl,
|
||||
props: {
|
||||
context: new Context(
|
||||
this,
|
||||
this.projectData,
|
||||
this.app,
|
||||
new NodePositionsCalculator(),
|
||||
),
|
||||
context: this.context,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -70,10 +80,7 @@ export class TaskmapView extends TextFileView {
|
|||
|
||||
async save() {
|
||||
try {
|
||||
await this.app.vault.modify(
|
||||
this.file!,
|
||||
this.projectData.serialize(),
|
||||
);
|
||||
await updateFile(this.app, this.file!, this.projectData);
|
||||
} catch (err) {
|
||||
console.error("Save failed:", err);
|
||||
throw new Error(`Save failed. ${err.message}`);
|
||||
|
|
|
|||
56
src/Tooltip.ts
Normal file
56
src/Tooltip.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { setTooltip } from "obsidian";
|
||||
import { IconCode } from "./types";
|
||||
|
||||
/**
|
||||
* Svelte Action to add an Obsidian-native tooltip to an element.
|
||||
*
|
||||
* @param node - The DOM element to attach the tooltip to.
|
||||
* @param text - The text to display in the tooltip.
|
||||
*/
|
||||
export function tooltip(node: HTMLElement, text: string) {
|
||||
setTooltip(node, text, {
|
||||
placement: "top",
|
||||
});
|
||||
|
||||
return {
|
||||
update(newText: string) {
|
||||
// Update the tooltip if the text prop changes
|
||||
setTooltip(node, newText, {
|
||||
placement: "top",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getTooltipText(code: IconCode) {
|
||||
switch (code) {
|
||||
case IconCode.FOCUS:
|
||||
return "Focus";
|
||||
case IconCode.CREATE_LINKED_NOTE:
|
||||
return "Add link";
|
||||
case IconCode.REPARENT:
|
||||
return "Reparent";
|
||||
case IconCode.KEY:
|
||||
return "Block another task";
|
||||
case IconCode.LOCK:
|
||||
return "Add blocker task";
|
||||
case IconCode.REMOVE:
|
||||
return "Remove";
|
||||
case IconCode.REMOVE_SINGLE:
|
||||
return "Remove single task";
|
||||
case IconCode.REMOVE_MULTIPLE:
|
||||
return "Remove task branch";
|
||||
case IconCode.STATUS:
|
||||
return "Status";
|
||||
case IconCode.STATUS_DRAFT:
|
||||
return "Draft";
|
||||
case IconCode.STATUS_READY:
|
||||
return "Ready";
|
||||
case IconCode.STATUS_IN_PROGRESS:
|
||||
return "In progress";
|
||||
case IconCode.STATUS_DONE:
|
||||
return "Done";
|
||||
default:
|
||||
return "placeholder tooltip";
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
Trash2,
|
||||
Unplug
|
||||
} from 'lucide-svelte';
|
||||
import {getTooltipText, tooltip} from "../Tooltip";
|
||||
|
||||
let {
|
||||
iconCode,
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
</script>
|
||||
|
||||
<div class="button"
|
||||
use:tooltip={getTooltipText(iconCode)}
|
||||
class:no-pan={true}
|
||||
class:is-pressed-up={isPressed}
|
||||
class:is-pressed-down={isPressedDown}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
if (context.isReparentingOn() && context.isValidReparentingTarget(taskId)) {
|
||||
context.finishReparenting(taskId);
|
||||
} else {
|
||||
context.pressedButtonCode = -1;
|
||||
context.setSelectedTaskId(taskData.taskId);
|
||||
// const input = (document.getElementById('titleInput') as HTMLInputElement);
|
||||
// input.disabled = false;
|
||||
|
|
@ -75,7 +76,9 @@
|
|||
onmouseenter={() => isHovered = true}
|
||||
onmouseleave={() => isHovered = false}
|
||||
onpointerdown={(e: PointerEvent) => {
|
||||
context.startTaskDragging(e, taskId);
|
||||
if (context.editingTaskId === NoTaskId) {
|
||||
context.startTaskDragging(e, taskId);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onpointerup={onPointerUp}
|
||||
|
|
@ -87,12 +90,6 @@
|
|||
{isUnselected}
|
||||
{context}
|
||||
app={context.app}
|
||||
content={taskData.name}
|
||||
onSave={(newContent)=> {
|
||||
taskData.name = newContent;
|
||||
context.save();
|
||||
}}
|
||||
file={context.view.getFile()}
|
||||
/>
|
||||
</div>
|
||||
{#if !context.taskDraggingManager.isDragging}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import {MarkdownRenderer, Component, App, type TFile} from "obsidian";
|
||||
import {onMount, tick} from "svelte";
|
||||
import {App, Component, MarkdownRenderer} from "obsidian";
|
||||
import {LinkSuggest} from "../LinkSuggest";
|
||||
import type {Context} from "../Context.svelte.js";
|
||||
import {getFromRelativePath, isLink} from "../LinkManager";
|
||||
import type {TaskData} from "../types";
|
||||
import {NoTaskId} from "../NodePositionsCalculator";
|
||||
|
||||
// PROPS
|
||||
let {
|
||||
|
|
@ -10,29 +13,22 @@
|
|||
isUnselected,
|
||||
context,
|
||||
app,
|
||||
content,
|
||||
file,
|
||||
onSave
|
||||
}: {
|
||||
taskId: number,
|
||||
isUnselected: boolean,
|
||||
context: Context,
|
||||
app: App;
|
||||
content: string;
|
||||
file: TFile;
|
||||
onSave: (newContent: string) => void
|
||||
} = $props();
|
||||
|
||||
// STATE
|
||||
|
||||
let taskData = context.projectData.getTask(taskId);
|
||||
let isSelected = $derived(context.isSelected(taskId));
|
||||
let isEditing = $state(false);
|
||||
let isEditing = $derived(context.editingTaskId === taskId);
|
||||
let suggest: LinkSuggest | null = null;
|
||||
let textPreviewEl: HTMLElement;
|
||||
let textEditEl: HTMLTextAreaElement;
|
||||
let component = new Component(); // Required by Obsidian to manage render lifecycle
|
||||
let isDragging = $derived(context.taskDraggingManager.isDragging);
|
||||
|
||||
onMount(() => {
|
||||
if(!isEditing && textPreviewEl) {
|
||||
renderMarkdown();
|
||||
|
|
@ -43,11 +39,9 @@
|
|||
};
|
||||
});
|
||||
$effect(() => {
|
||||
if (!isEditing && textPreviewEl) {
|
||||
if (textPreviewEl) {
|
||||
renderMarkdown();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (context.taskDraggingManager.isDragging) {
|
||||
document.body.classList.add('is-dragging-task');
|
||||
} else {
|
||||
|
|
@ -64,40 +58,21 @@
|
|||
}
|
||||
context.finishTaskDragging(e);
|
||||
console.log('task text clicked');
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if the clicked element (or its parent) is an internal link
|
||||
const internalLink = target.closest(".internal-link");
|
||||
if (internalLink) {
|
||||
// Prevent the default "Swap to Edit" behavior
|
||||
e.preventDefault(); // TODO: is this line needed?
|
||||
e.stopPropagation();
|
||||
|
||||
const href = internalLink.getAttribute("data-href");
|
||||
if (href === null) {
|
||||
throw new Error('href is null');
|
||||
}
|
||||
|
||||
// Open the link using Obsidian's API
|
||||
// Whether to open in a new tab or in current one
|
||||
// const openInNewLeaf = e.ctrlKey || e.metaKey;
|
||||
// app.workspace.openLinkText(href, sourcePath, openInNewLeaf);
|
||||
const filepath = context.filePathFromTask(taskId);
|
||||
const file = context.app.vault.getAbstractFileByPath(filepath) as TFile;
|
||||
context.openOrFocusNote(file);
|
||||
return;
|
||||
}
|
||||
|
||||
const externalLink = target.closest("a.external-link");
|
||||
if (externalLink){
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.isSelected(taskId)) {
|
||||
toggleEdit();
|
||||
e.stopPropagation();
|
||||
toggleEdit();
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
const link = target.closest(".internal-link");
|
||||
if (link && taskData.path) {
|
||||
const file = getFromRelativePath(context.app, taskData.path);
|
||||
if (file) {
|
||||
e.preventDefault(); // TODO: is this line needed?
|
||||
e.stopPropagation();
|
||||
context.openOrFocusNote(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewMouseOver(e: MouseEvent) {
|
||||
|
|
@ -119,26 +94,17 @@
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMarkdown() {
|
||||
textPreviewEl.empty(); // Clear previous render
|
||||
if (file !== undefined) {
|
||||
await MarkdownRenderer.render(
|
||||
app,
|
||||
`[[${file.path}]]`,
|
||||
textPreviewEl,
|
||||
"",
|
||||
component,
|
||||
);
|
||||
} else {
|
||||
await MarkdownRenderer.render(
|
||||
app,
|
||||
content,
|
||||
textPreviewEl,
|
||||
"",
|
||||
component,
|
||||
);
|
||||
|
||||
}
|
||||
const content = taskData.name;
|
||||
await MarkdownRenderer.render(
|
||||
app,
|
||||
content,
|
||||
textPreviewEl,
|
||||
"",
|
||||
component,
|
||||
);
|
||||
// After rendering, find all links and disable their native dragging
|
||||
const links = textPreviewEl.querySelectorAll('a, .internal-link, .external-link');
|
||||
links.forEach(link => {
|
||||
|
|
@ -149,7 +115,7 @@
|
|||
}
|
||||
|
||||
async function toggleEdit() {
|
||||
isEditing = true;
|
||||
context.editingTaskId = taskId;
|
||||
// Wait for DOM update so textarea exists, then focus
|
||||
await tick();
|
||||
if (textEditEl) {
|
||||
|
|
@ -158,11 +124,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
export function handleBlur() {
|
||||
isEditing = false;
|
||||
onSave(content); // Persist changes
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
|
@ -173,7 +134,31 @@
|
|||
textEditEl.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlur() {
|
||||
// TODO: throws error because text preview doesn't exit when blur on textedit happens
|
||||
context.editingTaskId = NoTaskId;
|
||||
handleInput();
|
||||
await tick(); // Wait for DOM to render preview element
|
||||
await renderMarkdown();
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (textEditEl === null) {
|
||||
return;
|
||||
}
|
||||
taskData.name = textEditEl.value;
|
||||
if (isLink(taskData.name)) {
|
||||
const file = context.linkManager.getFromLink(taskData.name);
|
||||
if (file === null) {
|
||||
throw new Error(`Link [${taskData.name}] points to a nonexistent file`);
|
||||
}
|
||||
taskData.path = file.path;
|
||||
} else {
|
||||
taskData.path = undefined;
|
||||
}
|
||||
context.save();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
|
@ -189,10 +174,10 @@
|
|||
class:unselect={isUnselected}
|
||||
maxlength="28"
|
||||
bind:this={textEditEl}
|
||||
bind:value={content}
|
||||
onblur={handleBlur}
|
||||
onkeydown={handleKeydown}
|
||||
></textarea>
|
||||
oninput={handleInput}
|
||||
>{taskData.name}</textarea>
|
||||
{:else}
|
||||
<div
|
||||
class="text-preview tasktext"
|
||||
|
|
|
|||
|
|
@ -159,7 +159,11 @@
|
|||
{/each}
|
||||
|
||||
</div>
|
||||
<Toolbar context={context}/>
|
||||
{#if context.selectedTaskId !== -1}
|
||||
{#key context.selectedTaskId}
|
||||
<Toolbar context={context} taskId={context.selectedTaskId}/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,14 +11,16 @@
|
|||
import {quintOut} from 'svelte/easing';
|
||||
import {slideCustom} from '../Custom';
|
||||
import type {Context} from "../Context.svelte.js";
|
||||
import {IconCode, toIconCode} from "../types";
|
||||
import {IconCode, StatusCode, type TaskId, toIconCode} from "../types";
|
||||
import {RootTaskId} from "../NodePositionsCalculator";
|
||||
|
||||
let {
|
||||
taskId,
|
||||
context
|
||||
}: {context: Context} = $props();
|
||||
}: {taskId: TaskId,
|
||||
context: Context} = $props();
|
||||
|
||||
let position = $derived(context.getCurrentTaskPosition(context.selectedTaskId));
|
||||
let position = $derived(context.getCurrentTaskPosition(taskId));
|
||||
|
||||
function getTop() {
|
||||
return position.y - TOOLBAR_SIZE.height - TOOLBAR_SHIFT;
|
||||
|
|
@ -26,76 +28,97 @@
|
|||
function getLeft() {
|
||||
return position.x + TASK_SIZE.width_hovered / 2;
|
||||
}
|
||||
let isLeafTask = $derived(context.projectData.getChildren(taskId).length === 0);
|
||||
|
||||
let isLeafTask = $derived(context.selectedTaskId != -1 && context.projectData.getChildren(context.selectedTaskId).length === 0);
|
||||
let toolbarButtons = $derived(taskId == RootTaskId ? [
|
||||
IconCode.CREATE_LINKED_NOTE,
|
||||
IconCode.FOCUS,
|
||||
IconCode.STATUS
|
||||
|
||||
] : [
|
||||
IconCode.CREATE_LINKED_NOTE,
|
||||
IconCode.REMOVE,
|
||||
IconCode.REPARENT,
|
||||
IconCode.KEY,
|
||||
IconCode.LOCK,
|
||||
IconCode.FOCUS,
|
||||
IconCode.STATUS
|
||||
]);
|
||||
|
||||
let removeButtons = [
|
||||
IconCode.REMOVE_SINGLE,
|
||||
IconCode.REMOVE_MULTIPLE
|
||||
];
|
||||
|
||||
let statusButtons = $derived((isLeafTask ? [
|
||||
StatusCode.DRAFT,
|
||||
StatusCode.READY,
|
||||
StatusCode.IN_PROGRESS,
|
||||
StatusCode.DONE,
|
||||
]: [
|
||||
StatusCode.DRAFT,
|
||||
context.projectData.calculateStatus(taskId)
|
||||
]).map(x => toIconCode(x)));
|
||||
|
||||
let subtoolbarTopShift = (buttons: IconCode[]) => {
|
||||
return - (buttons.length * BUTTON_SIZE + (buttons.length -1)*TOOLBAR_GAP + 2*TOOLBAR_PADDING.y + SUBTOOLBAR_SHIFT);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
{#if context.selectedTaskId !== -1 && !context.taskDraggingManager.isDragging}
|
||||
<div
|
||||
class="toolbar"
|
||||
class:no-pan={true}
|
||||
in:fade={{ duration: 500 }}
|
||||
out:fade={{ duration: 300 }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onpointerup={(e) => e.stopPropagation()}
|
||||
style="
|
||||
top: {getTop()}px;
|
||||
left: {getLeft()}px;
|
||||
"
|
||||
>
|
||||
{#key context.updateOnZoomCounter}
|
||||
<Button iconCode={IconCode.CREATE_LINKED_NOTE} {context} />
|
||||
{#if context.selectedTaskId != RootTaskId}
|
||||
<Button iconCode={IconCode.REMOVE} {context}/>
|
||||
<Button iconCode={IconCode.REPARENT} {context} />
|
||||
<Button iconCode={IconCode.KEY} {context} />
|
||||
<Button iconCode={IconCode.LOCK} {context} />
|
||||
{#if !context.taskDraggingManager.isDragging}
|
||||
<div
|
||||
class="toolbar"
|
||||
class:no-pan={true}
|
||||
in:fade|global={{ duration: 500 }}
|
||||
out:fade|global={{ duration: 300 }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onpointerup={(e) => e.stopPropagation()}
|
||||
style="
|
||||
top: {getTop()}px;
|
||||
left: {getLeft()}px;
|
||||
"
|
||||
>
|
||||
{#key context.updateOnZoomCounter}
|
||||
{#each toolbarButtons as button}
|
||||
<Button iconCode={button} {context} />
|
||||
{/each}
|
||||
{/key}
|
||||
|
||||
{#if context.pressedButtonCode === IconCode.REMOVE}
|
||||
<div
|
||||
class="subtoolbar"
|
||||
transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }}
|
||||
style="
|
||||
top: {subtoolbarTopShift(removeButtons)}px;
|
||||
left: {toolbarButtons.indexOf(IconCode.REMOVE) * (BUTTON_SIZE + TOOLBAR_GAP) - 2}px;
|
||||
"
|
||||
>
|
||||
{#key context.updateOnZoomCounter}
|
||||
<Button iconCode={IconCode.REMOVE_SINGLE} {context} />
|
||||
<Button iconCode={IconCode.REMOVE_MULTIPLE} {context} />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
<Button iconCode={IconCode.FOCUS} {context} />
|
||||
<Button iconCode={IconCode.STATUS} {context} />
|
||||
{/key}
|
||||
|
||||
{#if context.pressedButtonCode === IconCode.REMOVE && context.selectedTaskId !== -1}
|
||||
<div
|
||||
class="subtoolbar"
|
||||
transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }}
|
||||
style="
|
||||
top: {- 2 * BUTTON_SIZE - TOOLBAR_GAP - 2 * TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT - 2}px;
|
||||
left: {1 * (BUTTON_SIZE + TOOLBAR_GAP) - 2}px;
|
||||
"
|
||||
>
|
||||
{#key context.updateOnZoomCounter}
|
||||
<Button iconCode={IconCode.REMOVE_SINGLE} {context} />
|
||||
<Button iconCode={IconCode.REMOVE_MULTIPLE} {context} />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if context.pressedButtonCode === IconCode.STATUS && context.selectedTaskId !== -1}
|
||||
<div
|
||||
class="subtoolbar"
|
||||
transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }}
|
||||
style="
|
||||
top: {- (isLeafTask ? 2 : 1) * 2 * BUTTON_SIZE - ((isLeafTask ? 2 : 0) + 1)*TOOLBAR_GAP - 2*TOOLBAR_PADDING.y - SUBTOOLBAR_SHIFT - 2}px;
|
||||
left: {6 * (BUTTON_SIZE + TOOLBAR_GAP) - 2}px;
|
||||
"
|
||||
>
|
||||
{#key context.updateOnZoomCounter}
|
||||
{#if isLeafTask}
|
||||
<Button iconCode={IconCode.STATUS_DRAFT} {context} />
|
||||
<Button iconCode={IconCode.STATUS_READY} {context} />
|
||||
<Button iconCode={IconCode.STATUS_IN_PROGRESS} {context} />
|
||||
<Button iconCode={IconCode.STATUS_DONE} {context} />
|
||||
{:else}
|
||||
<Button iconCode={IconCode.STATUS_DRAFT} {context} />
|
||||
<Button iconCode={toIconCode(context.projectData.calculateStatus(context.selectedTaskId))} {context} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if context.pressedButtonCode === IconCode.STATUS}
|
||||
<div
|
||||
class="subtoolbar"
|
||||
transition:slideCustom={{ duration: 300, easing: quintOut, axis: '-y' }}
|
||||
style="
|
||||
top: {subtoolbarTopShift(statusButtons)}px;
|
||||
left: {toolbarButtons.indexOf(IconCode.STATUS) * (BUTTON_SIZE + TOOLBAR_GAP) - 2}px;
|
||||
"
|
||||
>
|
||||
{#key context.updateOnZoomCounter}
|
||||
{#each statusButtons as button}
|
||||
<Button iconCode={button} {context} />
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
|
|
|
|||
123
src/main.ts
123
src/main.ts
|
|
@ -1,9 +1,19 @@
|
|||
import { addIcon, Plugin } from "obsidian";
|
||||
import { TaskmapView, VIEW_TYPE } from "./TaskmapView";
|
||||
import { type PluginSettings, DEFAULT_SETTINGS } from "./PluginSettings";
|
||||
import {
|
||||
addIcon,
|
||||
type App,
|
||||
Plugin,
|
||||
TAbstractFile,
|
||||
TFile,
|
||||
TFolder,
|
||||
} from "obsidian";
|
||||
import { TASKMAP_VIEW_TYPE, TaskmapView } from "./TaskmapView";
|
||||
import { DEFAULT_SETTINGS, type PluginSettings } from "./PluginSettings";
|
||||
import { TaskmapSettingTab } from "./TaskmapSettingTab";
|
||||
import { DEFAULT_DATA } from "./ProjectData.svelte";
|
||||
import { LOGO_CONTENT, LOGO_NAME } from "./IconService";
|
||||
import type { TaskData } from "./types";
|
||||
import { deserializeProjectData, updateFile } from "./SaveManager";
|
||||
import { generateMarkdownLink } from "./LinkManager";
|
||||
|
||||
export const FILE_EXTENSION = "taskmap";
|
||||
|
||||
|
|
@ -14,8 +24,8 @@ export default class TaskmapPlugin extends Plugin {
|
|||
async onload() {
|
||||
TaskmapPlugin.instance = this;
|
||||
await this.loadSettings();
|
||||
this.registerView(VIEW_TYPE, (leaf) => new TaskmapView(leaf));
|
||||
this.registerExtensions([FILE_EXTENSION], VIEW_TYPE);
|
||||
this.registerView(TASKMAP_VIEW_TYPE, (leaf) => new TaskmapView(leaf));
|
||||
this.registerExtensions([FILE_EXTENSION], TASKMAP_VIEW_TYPE);
|
||||
|
||||
// This creates an icon in the left ribbon.
|
||||
addIcon(LOGO_NAME, LOGO_CONTENT);
|
||||
|
|
@ -34,6 +44,99 @@ export default class TaskmapPlugin extends Plugin {
|
|||
|
||||
// // When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
||||
// this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
async (file: TAbstractFile, oldPath: string) => {
|
||||
console.log(`${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,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async handleRenameFiles(
|
||||
app: App,
|
||||
changedMdFiles: TFile[],
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
) {
|
||||
const taskmapFiles = this.app.vault
|
||||
.getFiles()
|
||||
.filter((file) => file.path.endsWith(".taskmap"));
|
||||
|
||||
let updatedTaskMapFilePaths: Set<string> = new Set<string>();
|
||||
|
||||
// update paths in taskmap files
|
||||
for (const taskmapFile of taskmapFiles) {
|
||||
console.log(`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.log("mapping " + JSON.stringify(mapping.keys()));
|
||||
let changed = false;
|
||||
changedMdFiles.forEach((mdFile) => {
|
||||
if (mdFile.path.contains("testNote")) {
|
||||
console.log(`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.log(`${taskmapFile.path} changed`);
|
||||
// resave file on the file system
|
||||
await updateFile(app, taskmapFile, projectData);
|
||||
updatedTaskMapFilePaths.add(taskmapFile.path);
|
||||
} else {
|
||||
console.log(`${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 static getActiveView() {
|
||||
|
|
@ -43,7 +146,7 @@ export default class TaskmapPlugin extends Plugin {
|
|||
}
|
||||
|
||||
public async createAndOpenDrawing(): Promise<string> {
|
||||
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
|
||||
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}`,
|
||||
|
|
@ -54,13 +157,13 @@ export default class TaskmapPlugin extends Plugin {
|
|||
|
||||
await leaf.openFile(file, { active: true });
|
||||
|
||||
leaf.setViewState({
|
||||
type: VIEW_TYPE,
|
||||
await leaf.setViewState({
|
||||
type: TASKMAP_VIEW_TYPE,
|
||||
state: leaf.view.getState(),
|
||||
});
|
||||
|
||||
this.app.workspace.revealLeaf(
|
||||
this.app.workspace.getLeavesOfType(VIEW_TYPE)[0],
|
||||
await this.app.workspace.revealLeaf(
|
||||
this.app.workspace.getLeavesOfType(TASKMAP_VIEW_TYPE)[0],
|
||||
);
|
||||
|
||||
return file.path;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export type TaskId = number;
|
|||
|
||||
export type TaskData = {
|
||||
name: string;
|
||||
path?: string | undefined | null;
|
||||
taskId: TaskId;
|
||||
status: StatusCode;
|
||||
deleted: boolean;
|
||||
|
|
|
|||
Loading…
Reference in a new issue