added undo redo buttons

This commit is contained in:
poanse 2026-02-20 03:58:33 +03:00
parent 2d25f61717
commit 9bf3bb6e13
11 changed files with 277 additions and 17 deletions

View file

@ -7,7 +7,7 @@ import TaskmapContainer from "./components/TaskmapContainer.svelte";
import { deserializeProjectData, updateFile } from "./SaveManager";
import type TaskmapPlugin from "./main";
import { VersionedData } from "./data/VersionedData";
import { HistoryManager } from "./data/HistoryManager";
import { HistoryManager } from "./data/HistoryManager.svelte";
export const TASKMAP_VIEW_TYPE = "taskmap-view";
@ -47,6 +47,7 @@ export class TaskmapView extends TextFileView {
this.app,
new NodePositionsCalculator(),
);
this.contentEl.addClass("taskmap-view-container");
this.taskmapContainer = mount(TaskmapContainer, {
target: this.contentEl,
props: {

View file

@ -1,5 +1,5 @@
import { setTooltip } from "obsidian";
import { IconCode } from "./types";
import { IconCode, SettingsIconCode } from "./types";
/**
* Svelte Action to add an Obsidian-native tooltip to an element.
@ -54,3 +54,16 @@ export function getTooltipText(code: IconCode) {
return "placeholder tooltip";
}
}
export function getTooltipTextSettings(code: SettingsIconCode) {
switch (code) {
case SettingsIconCode.SETTINGS_MENU:
return "WIP (Settings)";
case SettingsIconCode.SETTINGS_REDO:
return "Redo";
case SettingsIconCode.SETTINGS_UNDO:
return "Undo";
default:
return "placeholder tooltip";
}
}

View file

@ -0,0 +1,141 @@
<script lang="ts">
import type {Context} from "../Context.svelte.js";
import {SettingsIconCode} from "../types";
import {getTooltipTextSettings, tooltip} from "../Tooltip";
import {Redo2, Settings, Undo2} from 'lucide-svelte';
let { iconCode, context }: { iconCode: SettingsIconCode, context: Context } = $props();
let isPressedDown = $state(false);
function onpointerdown(event: MouseEvent) {
isPressedDown = true;
event.preventDefault();
event.stopPropagation();
}
function onBlur() {
isPressedDown = false;
}
let isButtonDisabled = $derived(
(iconCode == SettingsIconCode.NONE)
|| (iconCode == SettingsIconCode.SETTINGS_MENU)
|| (iconCode == SettingsIconCode.SETTINGS_UNDO && !context.versionedData.canUndo())
|| (iconCode == SettingsIconCode.SETTINGS_REDO && !context.versionedData.canRedo())
);
function onpointerup(event: MouseEvent) {
if (!isPressedDown) {
return;
}
if (isButtonDisabled) {
return;
}
isPressedDown = false;
event.stopPropagation();
if (iconCode == SettingsIconCode.SETTINGS_UNDO) {
context.versionedData.undo();
} else if (iconCode == SettingsIconCode.SETTINGS_REDO) {
context.versionedData.redo();
}
}
let classString = $derived(`
${(isPressedDown && !isButtonDisabled) ? 'is-pressed-down ': '' }
`);
</script>
<div class="button"
use:tooltip={getTooltipTextSettings(iconCode)}
class:disabled={isButtonDisabled}
class:no-pan={true}
class:is-pressed-down={isPressedDown}
{onpointerdown}
{onpointerup}
onmouseleave={onBlur}
onclick={(event: MouseEvent) => {
event.stopPropagation();
}}
role="presentation"
tabindex="-1"
>
{#if iconCode === SettingsIconCode.SETTINGS_MENU}
<Settings class={classString}/>
{:else if iconCode === SettingsIconCode.SETTINGS_UNDO}
<Undo2 class={classString}/>
{:else if iconCode === SettingsIconCode.SETTINGS_REDO}
<Redo2 class={classString}/>
{/if}
</div>
<style>
.button {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0px;
padding: 0px;
/*background-color: #e0e0e0;*/
cursor: pointer;
border-radius: 4px;
user-select: none;
/*background-color: #1E1E1E;*/
/* This handles the smooth color change for the SVG and Text */
transition: background-color 0.2s, color 0.2s;
:global(svg) {
transition: stroke 0.2s;
width: 14px;
height: 14px;
stroke: #bbb;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
will-change: transform,scale,translate;
}
.custom-svg {
stroke-width: 0.083;
fill: #bbb;
}
}
.button.disabled {
color: color-mix(in srgb, #7E7E7E 100%, #000000 50%);
:global(svg) {
stroke: grey;
}
:global(svg.done) {
stroke: color-mix(in srgb, #30623E 100%, #000000 50%);
}
}
.button:hover:not(.disabled){
background-color: #343434;
/*border-color: red;*/
border-width: 0;
outline: none;
}
.button.is-pressed-down:not(.disabled){
background-color: #343434;
color: white;
:global(svg) {
stroke: white;
}
}
.button.is-pressed-up:not(.disabled){
background-color: #343434;
color: white;
:global(svg) {
stroke: white;
}
}
</style>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import type {Context} from "../Context.svelte.js";
import {SettingsIconCode} from "../types";
import SettingsButton from "./SettingsButton.svelte";
let { context }: { context: Context } = $props();
</script>
{#if !context.taskDraggingManager.isDragging}
<div
class="settings-panel"
onclick={(e) => e.stopPropagation()}
onpointerdown={(e) => e.stopPropagation()}
onpointerup={(e) => e.stopPropagation()}
>
<SettingsButton iconCode={SettingsIconCode.SETTINGS_MENU} {context}/>
<div
class="undo-redo-container"
>
<SettingsButton iconCode={SettingsIconCode.SETTINGS_UNDO} {context}/>
<SettingsButton iconCode={SettingsIconCode.SETTINGS_REDO} {context}/>
</div>
</div>
{/if}
<style>
/* Figma: x 0, y 2, blur 4, spread 0, color #000000 (use the same opacity as in Figma) */
.settings-panel {
box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 1);
gap: 72px;
padding: 4px 4px;
background: #1E1E1E;
border-radius: 4px;
border-color: #1E1E1E;
border-style: solid;
border-width: 2px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
position: absolute;
will-change: transform,scale,translate;
width: 148px;
height: 28px;
bottom: 15px;
left: 15px;
/*transform: translate(-50%, -50%);*/
}
.undo-redo-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
</style>

View file

@ -6,6 +6,7 @@
import HideBranchButton from "./HideBranchButton.svelte";
import {NoTaskId} from "../NodePositionsCalculator";
import {KeyRound, LockKeyhole} from "lucide-svelte";
import {onMount} from "svelte";
const {
taskId,
@ -17,6 +18,15 @@
coords: {x: number, y: number}
} = $props();
let self: HTMLElement;
let viewport: HTMLElement;
onMount(() => {
viewport = self?.closest('.viewport') as HTMLElement;
return () => {
viewport.focus();
}
});
let taskData = $derived(context.versionedData.getTask(taskId));
// let coords = $derived(context.getTaskPosition(taskId));
@ -109,6 +119,7 @@
{#key context.updateOnZoomCounter}
<div
class="task-container"
bind:this={self}
style="
top: {coords.y}px;
left: {coords.x}px;
@ -138,11 +149,7 @@
onblur={() => finishEditing()}
role="presentation"
>
<TaskText
{taskId}
{isUnselected}
{context}
/>
<TaskText {taskId} {isUnselected} {context}/>
</div>
{#if !context.taskDraggingManager.isDragging
&& !context.isReparentingOn()

View file

@ -125,6 +125,7 @@
if (e.key === "Enter") {
e.preventDefault();
textEditEl.blur(); // Triggers handleBlur
// TODO: body gets focused instead of viewport
} else if (e.key === "Tab" && suggest !== null) {
// another hack to select suggest on tab
e.preventDefault();
@ -179,7 +180,6 @@
<div
class="text-preview tasktext"
class:unselect={isUnselected}
tabindex="0"
bind:this={textPreviewEl}
onpointerup={handlePreviewClick}
onmouseover={handlePreviewMouseOver}

View file

@ -9,6 +9,7 @@
import {NoTaskId, RootTaskId} from "../NodePositionsCalculator";
import {DraggingManager} from "../DraggingManager.svelte";
import {parseNumber} from "../Constants";
import SettingsPanel from "./SettingsPanel.svelte";
let {context}: {context: Context} = $props();
@ -63,7 +64,7 @@
return;
}
context.save();
context.incrementUpdateOnZoomCounter();// required for TaskText update
context.incrementUpdateOnZoomCounter(); // required for TaskText update
context.updateTaskPositions();
e.stopPropagation();
e.preventDefault();
@ -191,7 +192,6 @@
<div
class="task-layer"
tabindex="-1"
role="presentation"
>
{#each context.versionedData.getTasks().filter(t => !context.isTaskHidden(t.taskId)) as task (task.taskId)}
@ -236,8 +236,12 @@
{/if}
</div>
</div>
<SettingsPanel {context}/>
<style>
:global(.taskmap-view-container) {
padding: 0 !important;
}
.viewport {
width: 100%;
height: 100%;

View file

@ -186,6 +186,16 @@ export class SetTaskNameAction implements Action {
this.oldName = undefined;
this.oldPath = undefined;
}
shouldCombine(newAction: SetTaskNameAction) {
if (this.newPath !== newAction.newPath) {
return false;
}
// If both actions add one symbol or both remove 1 symbol then combine into one
const deltaLeft = this.newName.length - this.oldName!.length;
const deltaRight = newAction.newName.length - this.newName.length;
return deltaLeft === deltaRight;
}
}
export class SetTaskPriorityAction implements Action {

View file

@ -2,8 +2,8 @@
import type { Action } from "./Action";
export class HistoryManager {
private undoStack: Action[] = [];
private redoStack: Action[] = [];
private undoStack: Action[] = $state([]);
private redoStack: Action[] = $state([]);
execute(action: Action, data: ProjectData): void {
action.do(data);
@ -36,4 +36,8 @@ export class HistoryManager {
canRedo(): boolean {
return this.redoStack.length > 0;
}
lastAction() {
return this.undoStack.last();
}
}

View file

@ -1,4 +1,4 @@
import { HistoryManager } from "./HistoryManager";
import { HistoryManager } from "./HistoryManager.svelte";
import { type BlockerPair, StatusCode, type TaskId } from "../types";
import { ProjectData } from "./ProjectData.svelte";
import {
@ -35,6 +35,14 @@ export class VersionedData {
}
};
public canUndo = () => {
return this.history.canUndo();
};
public canRedo = () => {
return this.history.canRedo();
};
public addBlockerPair = (blockerPair: BlockerPair) => {
this.history.execute(new AddBlockerPairAction(blockerPair), this.data);
};
@ -93,10 +101,20 @@ export class VersionedData {
value: string,
path: string | undefined,
) => {
this.history.execute(
new SetTaskNameAction(taskId, value, path),
this.data,
);
const lastAction = this.history.lastAction();
const newAction = new SetTaskNameAction(taskId, value, path);
if (
lastAction instanceof SetTaskNameAction &&
lastAction.shouldCombine(newAction)
) {
this.history.undo(this.data);
this.history.execute(
new SetTaskNameAction(taskId, value, path),
this.data,
);
} else {
this.history.execute(newAction, this.data);
}
};
public changeParent = (taskId: TaskId, newParentId: TaskId) => {

View file

@ -16,6 +16,13 @@ export enum IconCode {
STATUS_DONE,
}
export enum SettingsIconCode {
NONE,
SETTINGS_MENU,
SETTINGS_UNDO,
SETTINGS_REDO,
}
export enum StatusCode {
DRAFT,
READY,