diff --git a/client/src/components/UndoRedo/UndoRedoStack.vue b/client/src/components/UndoRedo/UndoRedoStack.vue new file mode 100644 index 000000000000..e0b89b1cbe73 --- /dev/null +++ b/client/src/components/UndoRedo/UndoRedoStack.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/client/src/components/Workflow/Editor/Actions/commentActions.ts b/client/src/components/Workflow/Editor/Actions/commentActions.ts index 76a86c569a0d..00b6192e4a31 100644 --- a/client/src/components/Workflow/Editor/Actions/commentActions.ts +++ b/client/src/components/Workflow/Editor/Actions/commentActions.ts @@ -1,11 +1,20 @@ import { LazyUndoRedoAction, UndoRedoAction } from "@/stores/undoRedoStore"; -import { - type BaseWorkflowComment, - type WorkflowComment, - type WorkflowCommentColor, - type WorkflowCommentStore, +import type { + BaseWorkflowComment, + WorkflowComment, + WorkflowCommentColor, + WorkflowCommentStore, + WorkflowCommentType, } from "@/stores/workflowEditorCommentStore"; +function getCommentName(comment: { color: WorkflowCommentColor; type: WorkflowCommentType }) { + if (comment.color !== "none") { + return `${comment.color} ${comment.type} comment`; + } else { + return `${comment.type} comment`; + } +} + class CommentAction extends UndoRedoAction { protected store: WorkflowCommentStore; protected comment: WorkflowComment; @@ -15,11 +24,15 @@ class CommentAction extends UndoRedoAction { this.store = store; this.comment = structuredClone(comment) as WorkflowComment; } + + protected get commentName() { + return getCommentName(this.comment); + } } export class AddCommentAction extends CommentAction { get name() { - return `add ${this.comment.type} comment`; + return `add ${this.commentName}`; } undo() { @@ -33,7 +46,7 @@ export class AddCommentAction extends CommentAction { export class DeleteCommentAction extends CommentAction { get name() { - return `delete ${this.comment.type} comment`; + return `delete ${this.commentName}`; } run() { @@ -75,10 +88,11 @@ export class ChangeColorAction extends UndoRedoAction { } class LazyMutateCommentAction extends LazyUndoRedoAction { - private commentId: number; - private startData: WorkflowComment[K]; - private endData: WorkflowComment[K]; + protected commentId: number; + protected startData: WorkflowComment[K]; + protected endData: WorkflowComment[K]; protected type; + protected color: WorkflowCommentColor; protected applyDataCallback: (commentId: number, data: WorkflowComment[K]) => void; constructor( @@ -93,14 +107,19 @@ class LazyMutateCommentAction extends LazyUndoR this.endData = structuredClone(data); this.applyDataCallback = applyDataCallback; this.type = comment.type; + this.color = comment.color; } queued() { this.applyDataCallback(this.commentId, this.endData); } + protected get commentName() { + return getCommentName({ type: this.type, color: this.color }); + } + get name() { - return `change ${this.type} comment`; + return `change ${this.commentName}`; } updateData(data: WorkflowComment[K]) { @@ -122,6 +141,35 @@ export class LazyChangeDataAction extends LazyMutateCommentAction<"data"> { const callback = store.changeData; super(comment, "data", data, callback); } + + get name() { + type TitleData = { title: string }; + type TextData = { text: string }; + type SizeData = { size: number }; + type FormatData = { bold?: true; italic?: true }; + + if ((this.startData as TitleData).title !== (this.endData as TitleData).title) { + return `edit title of ${this.commentName}`; + } + + if ((this.startData as TextData).text !== (this.endData as TextData).text) { + return `edit text of ${this.commentName}`; + } + + if ((this.startData as SizeData).size !== (this.endData as SizeData).size) { + return `change text size of ${this.commentName}`; + } + + if ((this.startData as FormatData).bold !== (this.endData as FormatData).bold) { + return `toggle bold of ${this.commentName}`; + } + + if ((this.startData as FormatData).italic !== (this.endData as FormatData).italic) { + return `toggle italic of ${this.commentName}`; + } + + return super.name; + } } export class LazyChangePositionAction extends LazyMutateCommentAction<"position"> { @@ -131,7 +179,7 @@ export class LazyChangePositionAction extends LazyMutateCommentAction<"position" } get name() { - return `change ${this.type} comment position`; + return `move ${this.commentName}`; } } @@ -142,7 +190,7 @@ export class LazyChangeSizeAction extends LazyMutateCommentAction<"size"> { } get name() { - return `resize ${this.type} comment`; + return `resize ${this.commentName}`; } } diff --git a/client/src/components/Workflow/Editor/Actions/stepActions.ts b/client/src/components/Workflow/Editor/Actions/stepActions.ts index f6be31f65b4c..168846c66dfa 100644 --- a/client/src/components/Workflow/Editor/Actions/stepActions.ts +++ b/client/src/components/Workflow/Editor/Actions/stepActions.ts @@ -15,10 +15,11 @@ export class LazyMutateStepAction extends LazyUndoRedoActi toValue: Step[K]; stepId; stepStore; + stepLabel; onUndoRedo?: () => void; get name() { - return this.internalName ?? "modify step"; + return this.internalName ?? `modify step ${this.stepLabel}`; } set name(name: string | undefined) { @@ -32,6 +33,13 @@ export class LazyMutateStepAction extends LazyUndoRedoActi this.key = key; this.fromValue = fromValue; this.toValue = toValue; + + this.stepLabel = `${stepId + 1}`; + const step = this.stepStore.getStep(stepId); + + if (step) { + this.stepLabel = `"${stepId + 1}: ${step.label ?? step.name}"`; + } } queued() { @@ -160,10 +168,11 @@ export class UpdateStepAction extends UndoRedoAction { stepId; fromPartial; toPartial; + stepLabel; onUndoRedo?: () => void; get name() { - return this.internalName ?? "modify step"; + return this.internalName ?? `modify step ${this.stepLabel}`; } set name(name: string | undefined) { @@ -183,6 +192,13 @@ export class UpdateStepAction extends UndoRedoAction { this.stepId = stepId; this.fromPartial = fromPartial; this.toPartial = toPartial; + + this.stepLabel = `${stepId + 1}`; + const step = this.stepStore.getStep(stepId); + + if (step) { + this.stepLabel = `"${stepId + 1}: ${step.label ?? step.name}"`; + } } isEmpty() { @@ -310,7 +326,7 @@ export class RemoveStepAction extends UndoRedoAction { } get name() { - return `remove ${this.step.label ?? this.step.name}`; + return `remove step "${this.step.id} ${this.step.label ?? this.step.name}"`; } run() { @@ -331,6 +347,7 @@ export class CopyStepAction extends UndoRedoAction { stepStore; stateStore; step: NewStep; + stepLabel; stepId?: number; onUndoRedo?: () => void; @@ -340,12 +357,13 @@ export class CopyStepAction extends UndoRedoAction { this.stateStore = stateStore; const labelSet = getLabelSet(stepStore); + this.stepLabel = `${step.id + 1}: ${step.label ?? step.name}`; this.step = cloneStepWithUniqueLabel(step, labelSet); delete this.step.id; } get name() { - return `duplicate step ${this.step.label ?? this.step.name}`; + return `duplicate step "${this.stepLabel}"`; } run() { @@ -366,6 +384,7 @@ export class ToggleStepSelectedAction extends UndoRedoAction { stepStore; stepId; toggleTo: boolean; + stepLabel; constructor(stateStore: WorkflowStateStore, stepStore: WorkflowStepStore, stepId: number) { super(); @@ -374,11 +393,9 @@ export class ToggleStepSelectedAction extends UndoRedoAction { this.stepStore = stepStore; this.stepId = stepId; this.toggleTo = !this.stateStore.getStepMultiSelected(stepId); - } - get stepLabel() { const label = this.stepStore.getStep(this.stepId)?.label; - return label ?? `${this.stepId + 1}`; + this.stepLabel = label ?? `${this.stepId + 1}`; } get name() { @@ -445,6 +462,10 @@ export function useStepActions( undoRedoStore.setLazyActionTimeout(timeout); } + if (name) { + actionForKey.name = name; + } + return actionForKey; } else { const actionConstructor = @@ -469,11 +490,21 @@ export function useStepActions( } function setPosition(step: Step, position: NonNullable) { - changeValueOrCreateAction({ step, key: "position", value: position, name: "change step position" }); + changeValueOrCreateAction({ + step, + key: "position", + value: position, + name: `move step "${step.id + 1}: ${step.label ?? step.name}"`, + }); } function setAnnotation(step: Step, annotation: Step["annotation"]) { - changeValueOrCreateAction({ step, key: "annotation", value: annotation, name: "modify step annotation" }); + changeValueOrCreateAction({ + step, + key: "annotation", + value: annotation, + name: `edit annotation of step "${step.id + 1}: ${step.label ?? step.name}"`, + }); } function setOutputLabel( @@ -489,7 +520,7 @@ export function useStepActions( step, key: "workflow_outputs", value: workflowOutputs, - name: "modify step output label", + name: `edit output label of step "${step.id + 1}: ${step.label ?? step.name}"`, actionConstructor, keepActionAlive: true, timeout: 2000, @@ -502,7 +533,7 @@ export function useStepActions( step, key: "label", value: label, - name: "modify step label", + name: `change label of step ${step.id + 1} to "${label}"`, actionConstructor, keepActionAlive: true, timeout: 2000, diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index 71983b2421d0..1258bbca0a0a 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -59,6 +59,12 @@ @click="undoRedoStore.redo()"> + + +
-
+
+ import { library } from "@fortawesome/fontawesome-svg-core"; -import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { faArrowLeft, faArrowRight, faHistory } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { useMagicKeys, whenever } from "@vueuse/core"; import { logicAnd, logicNot, logicOr } from "@vueuse/math"; @@ -217,10 +225,11 @@ import WorkflowGraph from "./WorkflowGraph.vue"; import MarkdownEditor from "@/components/Markdown/MarkdownEditor.vue"; import FlexPanel from "@/components/Panels/FlexPanel.vue"; import ToolPanel from "@/components/Panels/ToolPanel.vue"; +import UndoRedoStack from "@/components/UndoRedo/UndoRedoStack.vue"; import FormDefault from "@/components/Workflow/Editor/Forms/FormDefault.vue"; import FormTool from "@/components/Workflow/Editor/Forms/FormTool.vue"; -library.add(faArrowLeft, faArrowRight); +library.add(faArrowLeft, faArrowRight, faHistory); export default { components: { @@ -238,6 +247,7 @@ export default { MessagesModal, WorkflowGraph, FontAwesomeIcon, + UndoRedoStack, }, props: { workflowId: { @@ -291,8 +301,18 @@ export default { } const showInPanel = ref("attributes"); + const showChanges = ref(false); + + function toggleShowChanges() { + ensureParametersSet(); + showChanges.value = !showChanges.value; + } + + function showAttributes(closeChanges = false) { + if (closeChanges) { + showChanges.value = false; + } - function showAttributes() { ensureParametersSet(); stateStore.activeNodeId = null; showInPanel.value = "attributes"; @@ -441,6 +461,8 @@ export default { parameters, ensureParametersSet, showInPanel, + showChanges, + toggleShowChanges, showAttributes, setName, report, @@ -498,12 +520,6 @@ export default { }; }, computed: { - attributesVisible() { - return this.showInPanel == "attributes"; - }, - showLint() { - return this.showInPanel == "lint"; - }, activeNodeType() { return this.activeStep?.type; }, @@ -706,10 +722,6 @@ export default { }); }); }, - onWorkflowTextEditor() { - this.stateStore.activeNodeId = null; - this.showInPanel = "attributes"; - }, onAnnotation(nodeId, newAnnotation) { this.stepActions.setAnnotation(this.steps[nodeId], newAnnotation); }, @@ -786,6 +798,7 @@ export default { this.ensureParametersSet(); this.stateStore.activeNodeId = null; this.showInPanel = "lint"; + this.showChanges = false; }, onUpgrade() { this.onAttemptRefactor([{ action_type: "upgrade_all_steps" }]); diff --git a/client/src/components/Workflow/Editor/Options.vue b/client/src/components/Workflow/Editor/Options.vue index c5e959598772..6c966f52be53 100644 --- a/client/src/components/Workflow/Editor/Options.vue +++ b/client/src/components/Workflow/Editor/Options.vue @@ -1,9 +1,25 @@ + diff --git a/client/src/composables/math.ts b/client/src/composables/math.ts new file mode 100644 index 000000000000..421a9ca24aa5 --- /dev/null +++ b/client/src/composables/math.ts @@ -0,0 +1,49 @@ +/** + * There are similar functions to those in this module in vue-use, but they only work one-way. + * Unlike vue-use, these composables return refs which can be set. + */ + +import { type MaybeRefOrGetter, toValue } from "@vueuse/core"; +import { computed, type Ref } from "vue"; + +/** + * Wraps a number ref, restricting it's values to a given range + * + * @param ref ref containing a number to wrap + * @param min lowest possible value of range + * @param max highest possible value of range + * @returns clamped ref + */ +export function useClamp(ref: Ref, min: MaybeRefOrGetter, max: MaybeRefOrGetter): Ref { + const clamp = (value: number) => { + return Math.min(Math.max(value, toValue(min)), toValue(max)); + }; + + const clampedRef = computed({ + get: () => clamp(ref.value), + set: (value) => (ref.value = clamp(value)), + }); + + return clampedRef; +} + +/** + * Wraps a number ref, restricting it's values to align to a given step size + * + * @param ref ref containing a number to wrap + * @param [stepSize = 1] size of steps to restrict value to. defaults to 1 + * @returns wrapped red + */ +export function useStep(ref: Ref, stepSize: MaybeRefOrGetter = 1): Ref { + const step = (value: number) => { + const stepSizeValue = toValue(stepSize); + return Math.round(value / stepSizeValue) * stepSizeValue; + }; + + const steppedRef = computed({ + get: () => step(ref.value), + set: (value) => (ref.value = step(value)), + }); + + return steppedRef; +} diff --git a/client/src/stores/undoRedoStore/index.ts b/client/src/stores/undoRedoStore/index.ts index da8c354e513f..f83dd6f6d103 100644 --- a/client/src/stores/undoRedoStore/index.ts +++ b/client/src/stores/undoRedoStore/index.ts @@ -1,5 +1,7 @@ import { computed, ref } from "vue"; +import { useClamp, useStep } from "@/composables/math"; +import { useUserLocalStorage } from "@/composables/userLocalStorage"; import { defineScopedStore } from "@/stores/scopedStore"; import { type LazyUndoRedoAction, UndoRedoAction } from "./undoRedoAction"; @@ -8,14 +10,34 @@ export { LazyUndoRedoAction, UndoRedoAction } from "./undoRedoAction"; export type UndoRedoStore = ReturnType; +export class ActionOutOfBoundsError extends Error { + public action: UndoRedoAction; + + constructor(action: UndoRedoAction, bounds: "undo" | "redo") { + super(`The action "${action.name}" is not in the ${bounds} stack`); + this.action = action; + } +} + export const useUndoRedoStore = defineScopedStore("undoRedoStore", () => { const undoActionStack = ref([]); const redoActionStack = ref([]); - const maxUndoActions = ref(100); + + const minUndoActions = ref(10); + const maxUndoActions = ref(10000); + + const savedUndoActionsValue = useUserLocalStorage(`undoRedoStore-savedUndoActions`, 100); + const savedUndoActions = useClamp(useStep(savedUndoActionsValue), minUndoActions, maxUndoActions); + + /** names of actions which were deleted due to savedUndoActions being exceeded */ + const deletedActions = ref([]); function $reset() { undoActionStack.value.forEach((action) => action.destroy()); undoActionStack.value = []; + deletedActions.value = []; + minUndoActions.value = 10; + maxUndoActions.value = 10000; clearRedoStack(); } @@ -44,8 +66,9 @@ export const useUndoRedoStore = defineScopedStore("undoRedoStore", () => { clearRedoStack(); undoActionStack.value.push(action); - while (undoActionStack.value.length > maxUndoActions.value && undoActionStack.value.length > 0) { + while (undoActionStack.value.length > savedUndoActions.value && undoActionStack.value.length > 0) { const action = undoActionStack.value.shift(); + deletedActions.value.push(action?.name ?? "unnamed action"); action?.destroy(); } } @@ -141,10 +164,39 @@ export const useUndoRedoStore = defineScopedStore("undoRedoStore", () => { } }); + function rollBackTo(action: UndoRedoAction) { + flushLazyAction(); + const undoSet = new Set(undoActionStack.value); + + if (!undoSet.has(action)) { + throw new ActionOutOfBoundsError(action, "undo"); + } + + while (nextRedoAction.value !== action) { + undo(); + } + } + + function rollForwardTo(action: UndoRedoAction) { + flushLazyAction(); + const redoSet = new Set(redoActionStack.value); + + if (!redoSet.has(action)) { + throw new ActionOutOfBoundsError(action, "redo"); + } + + while (nextUndoAction.value !== action) { + redo(); + } + } + return { undoActionStack, redoActionStack, + minUndoActions, maxUndoActions, + savedUndoActions, + deletedActions, undo, redo, applyAction, @@ -162,6 +214,8 @@ export const useUndoRedoStore = defineScopedStore("undoRedoStore", () => { hasUndo, hasRedo, $reset, + rollBackTo, + rollForwardTo, }; }); diff --git a/client/src/stores/undoRedoStore/undoRedoAction.ts b/client/src/stores/undoRedoStore/undoRedoAction.ts index 63a54f5e4ff4..88a42a5a0971 100644 --- a/client/src/stores/undoRedoStore/undoRedoAction.ts +++ b/client/src/stores/undoRedoStore/undoRedoAction.ts @@ -1,5 +1,12 @@ +let idCounter = 0; + export class UndoRedoAction { protected internalName?: string; + public id: number; + + constructor() { + this.id = idCounter++; + } get name(): string | undefined { return this.internalName; diff --git a/client/src/stores/workflowEditorCommentStore.ts b/client/src/stores/workflowEditorCommentStore.ts index 3a4ea5301fd6..d3a6c7e1993c 100644 --- a/client/src/stores/workflowEditorCommentStore.ts +++ b/client/src/stores/workflowEditorCommentStore.ts @@ -69,6 +69,8 @@ export type WorkflowComment = | MarkdownWorkflowComment | FreehandWorkflowComment; +export type WorkflowCommentType = WorkflowComment["type"]; + interface CommentsMetadata { justCreated?: boolean; multiSelected?: boolean;