;
+
+ // normalize
+ const normalized = simpleLine.map((p) => vecSubtract(p, freehandComment.position));
+
+ // reduce significant figures
+ const line = normalized.map((p) => vecReduceFigures(p) as Vector);
+
+ commentStore.changeData(freehandComment.id, { ...freehandComment.data, line });
+ commentStore.changePosition(freehandComment.id, vecReduceFigures(freehandComment.position));
+ commentStore.changeSize(freehandComment.id, vecReduceFigures(freehandComment.size));
+ commentStore.clearJustCreated(freehandComment.id);
+ };
+
+ const positionComment = (pointA: Vector, pointB: Vector, comment: BaseWorkflowComment) => {
+ if (toolbarStore.snapActive) {
+ pointA = vecSnap(pointA, toolbarStore.snapDistance);
+ pointB = vecSnap(pointB, toolbarStore.snapDistance);
+ }
+
+ const pointMin = vecMin(pointA, pointB);
+ const pointMax = vecMax(pointA, pointB);
+
+ const position = pointMin;
+ const size = vecSubtract(pointMax, pointMin);
+
+ commentStore.changePosition(comment.id, position);
+ commentStore.changeSize(comment.id, size);
+ };
+}
diff --git a/client/src/components/Workflow/Editor/WorkflowGraph.vue b/client/src/components/Workflow/Editor/WorkflowGraph.vue
index 5b02ddc0fbcd..59cafbf8bc5c 100644
--- a/client/src/components/Workflow/Editor/WorkflowGraph.vue
+++ b/client/src/components/Workflow/Editor/WorkflowGraph.vue
@@ -8,6 +8,7 @@
:viewport-bounding-box="viewportBoundingBox"
:transform="transform" />
+
diff --git a/client/src/stores/workflowEditorCommentStore.ts b/client/src/stores/workflowEditorCommentStore.ts
index 1c4e2030e6cc..4be998d2fbfc 100644
--- a/client/src/stores/workflowEditorCommentStore.ts
+++ b/client/src/stores/workflowEditorCommentStore.ts
@@ -2,7 +2,14 @@ import { defineStore } from "pinia";
import { computed, del, ref, set } from "vue";
import type { Colour } from "@/components/Workflow/Editor/Comments/colours";
-import { vecReduceFigures, Vector } from "@/components/Workflow/Editor/modules/geometry";
+import {
+ vecAdd,
+ vecMax,
+ vecMin,
+ vecReduceFigures,
+ vecSubtract,
+ Vector,
+} from "@/components/Workflow/Editor/modules/geometry";
import { assertDefined } from "@/utils/assertions";
import { hasKeys, match } from "@/utils/utils";
@@ -60,23 +67,23 @@ interface CommentsMetadata {
}
function assertCommentDataValid(
- annotationType: WorkflowComment["type"],
- annotationData: unknown
-): asserts annotationData is WorkflowComment["data"] {
- const valid = match(annotationType, {
- text: () => hasKeys(annotationData, ["text", "size"]),
- markdown: () => hasKeys(annotationData, ["text"]),
- frame: () => hasKeys(annotationData, ["title"]),
- freehand: () => hasKeys(annotationData, ["thickness", "line"]),
+ commentType: WorkflowComment["type"],
+ commentData: unknown
+): asserts commentData is WorkflowComment["data"] {
+ const valid = match(commentType, {
+ text: () => hasKeys(commentData, ["text", "size"]),
+ markdown: () => hasKeys(commentData, ["text"]),
+ frame: () => hasKeys(commentData, ["title"]),
+ freehand: () => hasKeys(commentData, ["thickness", "line"]),
});
if (!valid) {
- throw new TypeError(
- `Object "${annotationData}" is not a valid data object for an ${annotationType} annotation`
- );
+ throw new TypeError(`Object "${commentData}" is not a valid data object for an ${commentType} comment`);
}
}
+export type WorkflowCommentStore = ReturnType;
+
export const useWorkflowCommentStore = (workflowId: string) => {
return defineStore(`workflowCommentStore${workflowId}`, () => {
const commentsRecord = ref>({});
@@ -129,10 +136,63 @@ export const useWorkflowCommentStore = (workflowId: string) => {
set(comment, "colour", colour);
}
+ function addPoint(id: number, point: [number, number]) {
+ const comment = getComment.value(id);
+ if (!(comment.type === "freehand")) {
+ throw new Error("Can only add points to freehand comment");
+ }
+
+ comment.data.line.push(point);
+
+ comment.size = vecMax(comment.size, vecSubtract(point, comment.position));
+
+ const prevPosition = comment.position;
+ comment.position = vecMin(comment.position, point);
+
+ const diff = vecSubtract(prevPosition, comment.position);
+ comment.size = vecAdd(comment.size, diff);
+ }
+
function deleteComment(id: number) {
del(commentsRecord.value, id);
}
+ /**
+ * Adds a single comment. Sets the `userCreated` flag.
+ * Meant to be used when a user adds an comment.
+ * @param comment
+ */
+ function createComment(comment: BaseWorkflowComment) {
+ markJustCreated(comment.id);
+ addComments([comment as WorkflowComment]);
+ }
+
+ function markJustCreated(id: number) {
+ const metadata = localCommentsMetadata.value[id];
+
+ if (metadata) {
+ set(metadata, "justCreated", true);
+ } else {
+ set(localCommentsMetadata.value, id, { justCreated: true });
+ }
+ }
+
+ function clearJustCreated(id: number) {
+ const metadata = localCommentsMetadata.value[id];
+
+ if (metadata) {
+ del(metadata, "justCreated");
+ }
+ }
+
+ function deleteFreehandComments() {
+ Object.values(commentsRecord.value).forEach((comment) => {
+ if (comment.type === "freehand") {
+ deleteComment(comment.id);
+ }
+ });
+ }
+
return {
commentsRecord,
comments,
@@ -143,7 +203,11 @@ export const useWorkflowCommentStore = (workflowId: string) => {
changeSize,
changeData,
changeColour,
+ addPoint,
deleteComment,
+ createComment,
+ clearJustCreated,
+ deleteFreehandComments,
$reset,
};
})();
diff --git a/client/src/stores/workflowEditorToolbarStore.ts b/client/src/stores/workflowEditorToolbarStore.ts
index dbb28e77be68..2b3f0b2264f2 100644
--- a/client/src/stores/workflowEditorToolbarStore.ts
+++ b/client/src/stores/workflowEditorToolbarStore.ts
@@ -1,10 +1,24 @@
+import { useMagicKeys } from "@vueuse/core";
import { defineStore } from "pinia";
-import { ref } from "vue";
+import { computed, onScopeDispose, reactive, ref, watch } from "vue";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
+import { WorkflowCommentColour } from "./workflowEditorCommentStore";
+
export type CommentTool = "textComment" | "markdownComment" | "frameComment" | "freehandComment" | "freehandEraser";
export type EditorTool = "pointer" | CommentTool;
+export type InputCatcherEventType = "pointerdown" | "pointerup" | "pointermove" | "temporarilyDisabled";
+
+interface InputCatcherEventListener {
+ type: InputCatcherEventType;
+ callback: (event: InputCatcherEvent) => void;
+}
+
+interface InputCatcherEvent {
+ type: InputCatcherEventType;
+ position: [number, number];
+}
export type WorkflowEditorToolbarStore = ReturnType;
@@ -12,12 +26,68 @@ export const useWorkflowEditorToolbarStore = (workflowId: string) => {
return defineStore(`workflowEditorToolbarStore${workflowId}`, () => {
const snapActive = useUserLocalStorage("workflow-editor-toolbar-snap-active", false);
const currentTool = ref("pointer");
+ const inputCatcherActive = ref(false);
+ const inputCatcherEventListeners = new Set();
const snapDistance = ref<10 | 20 | 50 | 100 | 200>(10);
+ const commentOptions = reactive({
+ bold: false,
+ italic: false,
+ colour: "none" as WorkflowCommentColour,
+ textSize: 2,
+ lineThickness: 5,
+ smoothing: 2,
+ });
+
+ const inputCatcherPressed = ref(false);
+
+ function onInputCatcherEvent(type: InputCatcherEventType, callback: InputCatcherEventListener["callback"]) {
+ const listener = {
+ type,
+ callback,
+ };
+
+ inputCatcherEventListeners.add(listener);
+
+ onScopeDispose(() => {
+ inputCatcherEventListeners.delete(listener);
+ });
+ }
+
+ onInputCatcherEvent("pointerdown", () => (inputCatcherPressed.value = true));
+ onInputCatcherEvent("pointerup", () => (inputCatcherPressed.value = false));
+ onInputCatcherEvent("temporarilyDisabled", () => (inputCatcherPressed.value = false));
+
+ watch(
+ () => inputCatcherActive.value,
+ () => {
+ if (!inputCatcherActive.value) {
+ inputCatcherPressed.value = false;
+ }
+ }
+ );
+
+ const { shift, space, alt, ctrl } = useMagicKeys();
+ const inputCatcherEnabled = computed(() => !(shift?.value || space?.value || alt?.value || ctrl?.value));
+
+ function emitInputCatcherEvent(type: InputCatcherEventType, event: InputCatcherEvent) {
+ inputCatcherEventListeners.forEach((listener) => {
+ if (listener.type === type) {
+ listener.callback(event);
+ }
+ });
+ }
+
return {
snapActive,
snapDistance,
currentTool,
+ inputCatcherActive,
+ inputCatcherEnabled,
+ commentOptions,
+ inputCatcherPressed,
+ onInputCatcherEvent,
+ emitInputCatcherEvent,
};
})();
};
diff --git a/client/yarn.lock b/client/yarn.lock
index 9009acf3fca5..1e4a5ff24658 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -10369,6 +10369,11 @@ signal-exit@^4.0.1:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+simplify-js@^1.2.4:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/simplify-js/-/simplify-js-1.2.4.tgz#7aab22d6df547ffd40ef0761ccd82b75287d45c7"
+ integrity sha512-vITfSlwt7h/oyrU42R83mtzFpwYk3+mkH9bOHqq/Qw6n8rtR7aE3NZQ5fbcyCUVVmuMJR6ynsAhOfK2qoah8Jg==
+
sisteransi@^1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
From 3caaaa939cd0222b17673c6fb1cc4017b392da20 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Mon, 11 Sep 2023 16:31:54 +0200
Subject: [PATCH 13/43] draw Comments on Minimap
---
.../Workflow/Editor/WorkflowGraph.vue | 1 +
.../Workflow/Editor/WorkflowMinimap.vue | 82 ++++++++++++++++++-
2 files changed, 81 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Workflow/Editor/WorkflowGraph.vue b/client/src/components/Workflow/Editor/WorkflowGraph.vue
index 59cafbf8bc5c..ad165bd4fe8a 100644
--- a/client/src/components/Workflow/Editor/WorkflowGraph.vue
+++ b/client/src/components/Workflow/Editor/WorkflowGraph.vue
@@ -46,6 +46,7 @@
();
@@ -60,6 +68,15 @@ function recalculateAABB() {
}
});
+ props.comments.forEach((comment) => {
+ aabb.fitRectangle({
+ x: comment.position[0],
+ y: comment.position[1],
+ width: comment.size[0],
+ height: comment.size[1],
+ });
+ });
+
aabb.squareCenter();
aabb.expand(120);
@@ -70,9 +87,9 @@ function recalculateAABB() {
}
}
-// redraw if any steps change
+// redraw if any steps or comments change
watch(
- props.steps,
+ () => [props.steps, props.comments],
() => {
redraw = true;
aabbChanged = true;
@@ -140,6 +157,20 @@ function renderMinimap() {
// apply global to local transform
canvasTransform.applyToContext(ctx);
+ const frameComments: FrameWorkflowComment[] = [];
+ const markdownComments: MarkdownWorkflowComment[] = [];
+ const textComments: TextWorkflowComment[] = [];
+
+ props.comments.forEach((comment) => {
+ if (comment.type === "frame") {
+ frameComments.push(comment);
+ } else if (comment.type === "markdown") {
+ markdownComments.push(comment);
+ } else if (comment.type === "text") {
+ textComments.push(comment);
+ }
+ });
+
const allSteps = Object.values(props.steps);
const okSteps: Step[] = [];
const errorSteps: Step[] = [];
@@ -159,6 +190,53 @@ function renderMinimap() {
});
// draw rects
+
+ ctx.lineWidth = 2 / canvasTransform.scaleX;
+ frameComments.forEach((comment) => {
+ ctx.beginPath();
+
+ if (comment.colour !== "none") {
+ ctx.fillStyle = commentColours.brighterColours[comment.colour];
+ ctx.strokeStyle = commentColours.colours[comment.colour];
+ } else {
+ ctx.fillStyle = "rgba(0, 0, 0, 0)";
+ ctx.strokeStyle = colors.node;
+ }
+
+ ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
+ ctx.fill();
+ ctx.stroke();
+ });
+
+ ctx.fillStyle = "white";
+ markdownComments.forEach((comment) => {
+ ctx.beginPath();
+
+ if (comment.colour !== "none") {
+ ctx.strokeStyle = commentColours.colours[comment.colour];
+ } else {
+ ctx.strokeStyle = colors.node;
+ }
+
+ ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
+ ctx.fill();
+ ctx.stroke();
+ });
+
+ ctx.lineWidth = 1 / canvasTransform.scaleX;
+ textComments.forEach((comment) => {
+ ctx.beginPath();
+
+ if (comment.colour !== "none") {
+ ctx.strokeStyle = commentColours.brightColours[comment.colour];
+ } else {
+ ctx.strokeStyle = colors.node;
+ }
+
+ ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
+ ctx.stroke();
+ });
+
ctx.beginPath();
ctx.fillStyle = colors.node;
okSteps.forEach((step) => {
From d8865fbf7867ec801bfa29451e57730dfd9550ee Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 20 Sep 2023 13:49:37 +0200
Subject: [PATCH 14/43] add ColourSelector test stricter types in colour module
---
.../Editor/Comments/ColourSelector.test.js | 47 +++++++++++++++++++
.../Workflow/Editor/Comments/colours.ts | 11 +++--
2 files changed, 53 insertions(+), 5 deletions(-)
create mode 100644 client/src/components/Workflow/Editor/Comments/ColourSelector.test.js
diff --git a/client/src/components/Workflow/Editor/Comments/ColourSelector.test.js b/client/src/components/Workflow/Editor/Comments/ColourSelector.test.js
new file mode 100644
index 000000000000..60f03d9d59e8
--- /dev/null
+++ b/client/src/components/Workflow/Editor/Comments/ColourSelector.test.js
@@ -0,0 +1,47 @@
+import { mount } from "@vue/test-utils";
+import { nextTick } from "vue";
+
+import { colours } from "./colours";
+
+import ColourSelector from "./ColourSelector.vue";
+
+describe("ColourSelector", () => {
+ it("shows a button for each colour and 'none'", () => {
+ const wrapper = mount(ColourSelector, { propsData: { colour: "none" } });
+ const buttons = wrapper.findAll("button");
+ expect(buttons.length).toBe(Object.keys(colours).length + 1);
+ });
+
+ it("highlights the selected colour", async () => {
+ const wrapper = mount(ColourSelector, { propsData: { colour: "none" } });
+ const allSelected = wrapper.findAll(".selected");
+ expect(allSelected.length).toBe(1);
+
+ let selected = allSelected.wrappers[0];
+ expect(selected.element.getAttribute("title")).toBe("No Colour");
+
+ const colourNames = Object.keys(colours);
+
+ for (let i = 0; i < colourNames.length; i++) {
+ const colour = colourNames[i];
+ wrapper.setProps({ colour });
+ await nextTick();
+
+ selected = wrapper.find(".selected");
+
+ expect(selected.element.getAttribute("title")).toContain(colour);
+ }
+ });
+
+ it("emits the set colour", async () => {
+ const wrapper = mount(ColourSelector, { propsData: { colour: "none" } });
+
+ const colourNames = Object.keys(colours);
+
+ for (let i = 0; i < colourNames.length; i++) {
+ const colour = colourNames[i];
+ await wrapper.find(`[title="Colour ${colour}"]`).trigger("click");
+ expect(wrapper.emitted()["set-colour"][i][0]).toBe(colour);
+ }
+ });
+});
diff --git a/client/src/components/Workflow/Editor/Comments/colours.ts b/client/src/components/Workflow/Editor/Comments/colours.ts
index f2b630419473..f927f416706b 100644
--- a/client/src/components/Workflow/Editor/Comments/colours.ts
+++ b/client/src/components/Workflow/Editor/Comments/colours.ts
@@ -13,6 +13,7 @@ export const colours = {
} as const;
export type Colour = keyof typeof colours;
+export type HexColour = `#${string}`;
export const brightColours = (() => {
const brighter: Record = {};
@@ -23,7 +24,7 @@ export const brightColours = (() => {
hsluv[2] = l;
brighter[name] = hsluvToHex(hsluv);
});
- return brighter as Record;
+ return brighter as Record;
})();
export const brighterColours = (() => {
@@ -35,12 +36,12 @@ export const brighterColours = (() => {
hsluv[2] = l;
brighter[name] = hsluvToHex(hsluv);
});
- return brighter as Record;
+ return brighter as Record;
})();
export const darkenedColours = {
...colours,
- turquoise: "#00a6c0",
- lime: "#5eae00",
- yellow: "#e9ad00",
+ turquoise: "#00a6c0" as const,
+ lime: "#5eae00" as const,
+ yellow: "#e9ad00" as const,
};
From 6e9c4965fac024ca288a5df0824959d54108782c Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 20 Sep 2023 15:15:19 +0200
Subject: [PATCH 15/43] add WorkflowComment test
---
.../Editor/Comments/WorkflowComment.test.ts | 149 ++++++++++++++++++
1 file changed, 149 insertions(+)
create mode 100644 client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts
diff --git a/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts b/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts
new file mode 100644
index 000000000000..31c7c0dac2e0
--- /dev/null
+++ b/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts
@@ -0,0 +1,149 @@
+import { mount, shallowMount } from "@vue/test-utils";
+import { nextTick } from "vue";
+
+import MarkdownComment from "./MarkdownComment.vue";
+import TextComment from "./TextComment.vue";
+import WorkflowComment from "./WorkflowComment.vue";
+
+const changeData = jest.fn();
+const changeSize = jest.fn();
+const changePosition = jest.fn();
+const changeColour = jest.fn();
+const deleteComment = jest.fn();
+
+jest.mock("@/composables/workflowStores", () => ({
+ useWorkflowStores: () => ({
+ commentStore: {
+ changeData,
+ changeSize,
+ changePosition,
+ changeColour,
+ deleteComment,
+ isJustCreated: () => false,
+ },
+ }),
+}));
+
+function getStyleProperty(element: Element, property: string) {
+ const style = element.getAttribute("style") ?? "";
+
+ const startIndex = style.indexOf(`${property}:`) + property.length + 1;
+ const endIndex = style.indexOf(";", startIndex);
+
+ return style.substring(startIndex, endIndex).trim();
+}
+
+describe("WorkflowComment", () => {
+ const comment = {
+ id: 0,
+ type: "text",
+ colour: "none",
+ position: [0, 0],
+ size: [100, 100],
+ data: {},
+ };
+
+ it("changes position and size reactively", async () => {
+ const wrapper = shallowMount(WorkflowComment as any, {
+ propsData: {
+ comment: { ...comment },
+ scale: 1,
+ rootOffset: {},
+ },
+ });
+
+ const position = () => ({
+ x: parseInt(getStyleProperty(wrapper.element, "--position-left")),
+ y: parseInt(getStyleProperty(wrapper.element, "--position-top")),
+ });
+
+ const size = () => ({
+ w: parseInt(getStyleProperty(wrapper.element, "--width")),
+ h: parseInt(getStyleProperty(wrapper.element, "--height")),
+ });
+
+ expect(position()).toEqual({ x: 0, y: 0 });
+ expect(size()).toEqual({ w: 100, h: 100 });
+
+ await wrapper.setProps({
+ comment: { ...comment, position: [123, 456] },
+ scale: 1,
+ rootOffset: {},
+ });
+
+ expect(position()).toEqual({ x: 123, y: 456 });
+ expect(size()).toEqual({ w: 100, h: 100 });
+
+ await wrapper.setProps({
+ comment: { ...comment, size: [2000, 3000] },
+ scale: 1,
+ rootOffset: {},
+ });
+
+ expect(position()).toEqual({ x: 0, y: 0 });
+ expect(size()).toEqual({ w: 2000, h: 3000 });
+ });
+
+ it("displays the correct comment type", async () => {
+ const wrapper = mount(WorkflowComment as any, {
+ propsData: {
+ comment: { ...comment, type: "text", data: { size: 1, text: "HelloWorld" } },
+ scale: 1,
+ rootOffset: {},
+ },
+ });
+
+ expect(wrapper.findComponent(TextComment).isVisible()).toBe(true);
+
+ await wrapper.setProps({
+ comment: { ...comment, type: "markdown", data: { text: "# Hello World" } },
+ scale: 1,
+ rootOffset: {},
+ });
+
+ await nextTick();
+ expect(wrapper.findComponent(MarkdownComment).isVisible()).toBe(true);
+ });
+
+ it("forwards events to the comment store", () => {
+ const wrapper = mount(WorkflowComment as any, {
+ propsData: {
+ comment: { ...comment, id: 123, data: { size: 1, text: "HelloWorld" } },
+ scale: 1,
+ rootOffset: {},
+ },
+ });
+
+ const textComment = wrapper.findComponent(TextComment);
+
+ textComment.vm.$emit("change", "abc");
+ expect(changeData).toBeCalledWith(123, "abc");
+
+ textComment.vm.$emit("resize", [1000, 1000]);
+ expect(changeSize).toBeCalledWith(123, [1000, 1000]);
+
+ textComment.vm.$emit("move", [20, 20]);
+ expect(changePosition).toBeCalledWith(123, [20, 20]);
+
+ textComment.vm.$emit("set-colour", "pink");
+ expect(changeColour).toBeCalledWith(123, "pink");
+
+ textComment.vm.$emit("remove");
+ expect(deleteComment).toBeCalledWith(123);
+ });
+
+ it("forwards pan events", () => {
+ const wrapper = mount(WorkflowComment as any, {
+ propsData: {
+ comment: { ...comment, id: 123, data: { size: 1, text: "HelloWorld" } },
+ scale: 1,
+ rootOffset: {},
+ },
+ });
+
+ const textComment = wrapper.findComponent(TextComment);
+
+ textComment.vm.$emit("pan-by", { x: 50, y: 50 });
+ expect(wrapper.emitted()["pan-by"]?.[0]?.[0]).toEqual({ x: 50, y: 50 });
+ });
+});
From b6af3fb1711df02b6690a2e382fadc546f984811 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Mon, 25 Sep 2023 11:25:07 +0200
Subject: [PATCH 16/43] change editor default position this accommodates for
the extra space required by the editor tool bar
---
client/src/components/Workflow/Editor/WorkflowGraph.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/components/Workflow/Editor/WorkflowGraph.vue b/client/src/components/Workflow/Editor/WorkflowGraph.vue
index ad165bd4fe8a..1af61b9fe7db 100644
--- a/client/src/components/Workflow/Editor/WorkflowGraph.vue
+++ b/client/src/components/Workflow/Editor/WorkflowGraph.vue
@@ -94,7 +94,7 @@ const canvas: Ref = ref(null);
const elementBounding = useElementBounding(canvas, { windowResize: false, windowScroll: false });
const scroll = useScroll(canvas);
const { transform, panBy, setZoom, moveTo } = useD3Zoom(scale.value, minZoom, maxZoom, canvas, scroll, {
- x: 20,
+ x: 50,
y: 20,
});
const { viewportBoundingBox } = useViewportBoundingBox(elementBounding, scale, transform);
From 7d31c9cefe47a8adb16236262783b587a321cfbd Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Mon, 25 Sep 2023 11:50:09 +0200
Subject: [PATCH 17/43] add workflowEditorToolbarStore test
---
.../stores/workflowEditorToolbarStore.test.ts | 32 +++++++++++++++++++
.../src/stores/workflowEditorToolbarStore.ts | 2 +-
2 files changed, 33 insertions(+), 1 deletion(-)
create mode 100644 client/src/stores/workflowEditorToolbarStore.test.ts
diff --git a/client/src/stores/workflowEditorToolbarStore.test.ts b/client/src/stores/workflowEditorToolbarStore.test.ts
new file mode 100644
index 000000000000..f268c9c1cee2
--- /dev/null
+++ b/client/src/stores/workflowEditorToolbarStore.test.ts
@@ -0,0 +1,32 @@
+import { createPinia, setActivePinia } from "pinia";
+
+import { type InputCatcherEvent, useWorkflowEditorToolbarStore } from "./workflowEditorToolbarStore";
+
+describe("workflowEditorToolbarStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it("has an input catcher event bus", () => {
+ const toolbarStore = useWorkflowEditorToolbarStore("mock-workflow-id");
+
+ const receivedEvents: InputCatcherEvent[] = [];
+
+ toolbarStore.onInputCatcherEvent("pointerdown", (e) => receivedEvents.push(e));
+ toolbarStore.onInputCatcherEvent("pointerup", (e) => receivedEvents.push(e));
+ toolbarStore.onInputCatcherEvent("pointermove", (e) => receivedEvents.push(e));
+ toolbarStore.onInputCatcherEvent("temporarilyDisabled", (e) => receivedEvents.push(e));
+
+ toolbarStore.emitInputCatcherEvent("pointerdown", { type: "pointerdown", position: [100, 200] });
+ expect(receivedEvents.length).toBe(1);
+ expect(receivedEvents[0]?.position).toEqual([100, 200]);
+
+ toolbarStore.emitInputCatcherEvent("pointermove", { type: "pointermove", position: [0, 0] });
+ toolbarStore.emitInputCatcherEvent("pointerup", { type: "pointerup", position: [0, 0] });
+ toolbarStore.emitInputCatcherEvent("temporarilyDisabled", { type: "temporarilyDisabled", position: [0, 0] });
+
+ expect(receivedEvents[1]?.type).toBe("pointermove");
+ expect(receivedEvents[2]?.type).toBe("pointerup");
+ expect(receivedEvents[3]?.type).toBe("temporarilyDisabled");
+ });
+});
diff --git a/client/src/stores/workflowEditorToolbarStore.ts b/client/src/stores/workflowEditorToolbarStore.ts
index 2b3f0b2264f2..32bac6774b67 100644
--- a/client/src/stores/workflowEditorToolbarStore.ts
+++ b/client/src/stores/workflowEditorToolbarStore.ts
@@ -15,7 +15,7 @@ interface InputCatcherEventListener {
callback: (event: InputCatcherEvent) => void;
}
-interface InputCatcherEvent {
+export interface InputCatcherEvent {
type: InputCatcherEventType;
position: [number, number];
}
From 23d59b68ae12ccb62497ecfe04575b77ef97e9bf Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Mon, 25 Sep 2023 12:24:04 +0200
Subject: [PATCH 18/43] add workflowEditorCommentStore test
---
.../stores/workflowEditorCommentStore.test.ts | 130 ++++++++++++++++++
.../src/stores/workflowEditorCommentStore.ts | 1 +
2 files changed, 131 insertions(+)
create mode 100644 client/src/stores/workflowEditorCommentStore.test.ts
diff --git a/client/src/stores/workflowEditorCommentStore.test.ts b/client/src/stores/workflowEditorCommentStore.test.ts
new file mode 100644
index 000000000000..209c8eb601db
--- /dev/null
+++ b/client/src/stores/workflowEditorCommentStore.test.ts
@@ -0,0 +1,130 @@
+import { createPinia, setActivePinia } from "pinia";
+
+import {
+ FrameWorkflowComment,
+ type FreehandWorkflowComment,
+ MarkdownWorkflowComment,
+ type TextWorkflowComment,
+ useWorkflowCommentStore,
+} from "./workflowEditorCommentStore";
+
+const freehandComment: FreehandWorkflowComment = {
+ type: "freehand",
+ colour: "none",
+ data: {
+ thickness: 10,
+ line: [[100, 100]],
+ },
+ id: 0,
+ position: [100, 100],
+ size: [0, 0],
+};
+
+const textComment: TextWorkflowComment = {
+ type: "text",
+ colour: "none",
+ data: {
+ size: 1,
+ text: "Hello World",
+ },
+ id: 1,
+ position: [100, 100],
+ size: [0, 0],
+};
+
+const markdownComment: MarkdownWorkflowComment = {
+ type: "markdown",
+ colour: "none",
+ data: {
+ text: "# Hello World",
+ },
+ id: 2,
+ position: [100, 100],
+ size: [0, 0],
+};
+
+const frameComment: FrameWorkflowComment = {
+ type: "frame",
+ colour: "none",
+ data: {
+ title: "My Frame",
+ },
+ id: 3,
+ position: [100, 100],
+ size: [0, 0],
+};
+
+describe("workflowEditorCommentStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it("computes the position and size of a freehand comment", () => {
+ const commentStore = useWorkflowCommentStore("mock-id");
+
+ commentStore.createComment(freehandComment);
+ commentStore.addPoint(0, [10, 1000]);
+ commentStore.addPoint(0, [1000, 10]);
+
+ expect(commentStore.comments[0]?.position).toEqual([10, 10]);
+ expect(commentStore.comments[0]?.size).toEqual([990, 990]);
+ });
+
+ it("validates comment data", () => {
+ const commentStore = useWorkflowCommentStore("mock-id");
+
+ commentStore.addComments([freehandComment, textComment, markdownComment, frameComment]);
+
+ const testData = {
+ text: "Hello World",
+ };
+
+ expect(() => commentStore.changeData(0, testData)).toThrow(TypeError);
+ expect(() => commentStore.changeData(1, testData)).toThrow(TypeError);
+ expect(() => commentStore.changeData(2, testData)).not.toThrow();
+ expect(() => commentStore.changeData(3, testData)).toThrow(TypeError);
+
+ expect(() => commentStore.changeData(0, {})).toThrow(TypeError);
+ expect(() => commentStore.changeData(1, {})).toThrow(TypeError);
+ expect(() => commentStore.changeData(2, {})).toThrow(TypeError);
+ expect(() => commentStore.changeData(3, {})).toThrow(TypeError);
+
+ const duckTypedTestData = {
+ text: "Hello World",
+ size: 2,
+ };
+
+ expect(() => commentStore.changeData(0, duckTypedTestData)).toThrow(TypeError);
+ expect(() => commentStore.changeData(1, duckTypedTestData)).not.toThrow();
+ expect(() => commentStore.changeData(2, duckTypedTestData)).not.toThrow();
+ expect(() => commentStore.changeData(3, duckTypedTestData)).toThrow(TypeError);
+ });
+
+ it("does not mutate input data", () => {
+ const commentStore = useWorkflowCommentStore("mock-id");
+ const comment: TextWorkflowComment = { ...textComment, id: 0, colour: "pink" };
+
+ commentStore.addComments([comment]);
+ commentStore.changeColour(0, "blue");
+
+ expect(commentStore.comments[0]?.colour).toBe("blue");
+ expect(comment.colour).toBe("pink");
+ });
+
+ it("implements reset", () => {
+ const commentStore = useWorkflowCommentStore("mock-id");
+ commentStore.createComment({ ...textComment, id: 100 });
+
+ expect(commentStore.comments.length).toBe(1);
+ expect(commentStore.commentsRecord).not.toEqual({});
+ expect(commentStore.isJustCreated(100)).toBe(true);
+ expect(commentStore.highestCommentId).toBe(100);
+
+ commentStore.$reset();
+
+ expect(commentStore.comments.length).toBe(0);
+ expect(commentStore.commentsRecord).toEqual({});
+ expect(commentStore.isJustCreated(100)).toBe(false);
+ expect(commentStore.highestCommentId).toBe(-1);
+ });
+});
diff --git a/client/src/stores/workflowEditorCommentStore.ts b/client/src/stores/workflowEditorCommentStore.ts
index 4be998d2fbfc..1d273f310821 100644
--- a/client/src/stores/workflowEditorCommentStore.ts
+++ b/client/src/stores/workflowEditorCommentStore.ts
@@ -93,6 +93,7 @@ export const useWorkflowCommentStore = (workflowId: string) => {
function $reset() {
commentsRecord.value = {};
+ localCommentsMetadata.value = {};
}
const addComments = (commentsArray: WorkflowComment[], defaultPosition: [number, number] = [0, 0]) => {
From 8b38584d610d8da7412330a0a73a57dadbe9fbc6 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Mon, 25 Sep 2023 15:06:52 +0200
Subject: [PATCH 19/43] add selenium test for snapping
---
.../Editor/Comments/ColourSelector.vue | 2 +
.../Workflow/Editor/Tools/ToolBar.vue | 34 +++++++++++--
client/src/utils/navigation/navigation.yml | 23 +++++++++
.../selenium/test_workflow_editor.py | 48 +++++++++++++++++++
4 files changed, 103 insertions(+), 4 deletions(-)
diff --git a/client/src/components/Workflow/Editor/Comments/ColourSelector.vue b/client/src/components/Workflow/Editor/Comments/ColourSelector.vue
index 6f7cd650faf2..2faa53447830 100644
--- a/client/src/components/Workflow/Editor/Comments/ColourSelector.vue
+++ b/client/src/components/Workflow/Editor/Comments/ColourSelector.vue
@@ -21,12 +21,14 @@ function onClickColour(colour: WorkflowCommentColour) {
@@ -235,6 +255,7 @@ whenever(ctrl_7!, () => (toolbarStore.currentTool = "freehandEraser"));
(toolbarStore.currentTool = "freehandEraser"));
(toolbarStore.currentTool = "freehandEraser"));
-
+
Remove all
diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml
index ce285d29982b..91f2ab3184a3 100644
--- a/client/src/utils/navigation/navigation.yml
+++ b/client/src/utils/navigation/navigation.yml
@@ -682,6 +682,29 @@ workflow_editor:
connector_destroy_callout: "${_} [input-name='${name}'] + .delete-terminal-button"
workflow_output_toggle: "${_} [data-output-name='${name}'] .callout-terminal "
workflow_output_toggle_active: "${_} [data-output-name='${name}'] .mark-terminal-active"
+ tool_bar:
+ selectors:
+ _: ".workflow-editor-toolbar"
+ tool: "[data-tool='${tool}']"
+ tool_active: ".active[data-tool='${tool}']"
+ snapping_distance: "[data-option='snapping-distance']"
+ toggle_bold: "[data-option='toggle-bold']"
+ toggle_italic: "[data-option='toggle-italic']"
+ colour: ".colour-selector [data-colour='${colour}']"
+ font_size: "[data-option='font-size']"
+ line_thickness: "[data-option='line-thickness']"
+ smoothing: "[data-option='smoothing']"
+ remove_freehand: "[data-option='remove-freehand']"
+ comment:
+ selectors:
+ _: ".workflow-editor-comment"
+ text: ".text-workflow-comment"
+ markdown: ".markdown-workflow-comment"
+ markdown_rendered: ".markdown-workflow-comment .rendered-markdown"
+ frame: ".frame-workflow-comment"
+ frame_title: ".frame-workflow-comment .frame-comment-header"
+ freehand: ".freehand-workflow-comment"
+ freehand_path: ".freehand-workflow-comment path"
selectors:
canvas_body: '#workflow-canvas'
edit_annotation: '#workflow-annotation'
diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py
index 74c02f091e56..d57474e39048 100644
--- a/lib/galaxy_test/selenium/test_workflow_editor.py
+++ b/lib/galaxy_test/selenium/test_workflow_editor.py
@@ -1084,6 +1084,54 @@ def test_map_over_output_indicator(self):
self.workflow_editor_destroy_connection("filter#how|filter_source")
self.assert_node_output_is("filter#output_filtered", "list")
+ @selenium_test
+ def test_editor_snapping(self):
+ editor = self.components.workflow_editor
+ self.workflow_create_new(annotation="simple workflow")
+ self.sleep_for(self.wait_types.UX_RENDER)
+
+ editor.tool_menu.wait_for_visible()
+
+ self.tool_open("cat")
+ self.sleep_for(self.wait_types.UX_RENDER)
+ editor.label_input.wait_for_and_send_keys("tool_node")
+
+ # activate snapping and set it to max (200)
+ editor.tool_bar.tool(tool="toggle_snap").wait_for_and_click()
+ editor.tool_bar.snapping_distance.wait_for_and_click()
+ self.action_chains().send_keys(Keys.RIGHT * 10).perform()
+
+ # move the node a bit
+ tool_node = editor.node._(label="tool_node").wait_for_present()
+ self.action_chains().move_to_element(tool_node).click_and_hold().move_by_offset(12, 3).release().perform()
+
+ # check if editor position is snapped
+ top, left = self.get_node_position("tool_node")
+
+ assert top % 200 == 0
+ assert left % 200 == 0
+
+ # move the node a bit more
+ tool_node = editor.node._(label="tool_node").wait_for_present()
+ self.action_chains().move_to_element(tool_node).click_and_hold().move_by_offset(207, -181).release().perform()
+
+ # check if editor position is snapped
+ top, left = self.get_node_position("tool_node")
+
+ assert top % 200 == 0
+ assert left % 200 == 0
+
+ def get_node_position(self, label: str):
+ node = self.components.workflow_editor.node._(label=label).wait_for_present()
+
+ left: str = node.value_of_css_property("left")
+ top: str = node.value_of_css_property("top")
+
+ left_stripped = "".join(char for char in left if char.isdigit())
+ top_stripped = "".join(char for char in top if char.isdigit())
+
+ return (int(left_stripped), int(top_stripped))
+
def assert_node_output_is(self, label: str, output_type: str, subcollection_type: Optional[str] = None):
editor = self.components.workflow_editor
node_label, output_name = label.split("#")
From 36e849c4eaf08a163ad5a76b819b247bb2920366 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 26 Sep 2023 15:33:28 +0200
Subject: [PATCH 20/43] add selenium test for placing and removing comments
---
.../Editor/Comments/MarkdownComment.vue | 2 +-
client/src/utils/navigation/navigation.yml | 10 +-
lib/galaxy/selenium/navigates_galaxy.py | 24 +++
.../selenium/test_workflow_editor.py | 146 +++++++++++++++++-
4 files changed, 175 insertions(+), 7 deletions(-)
diff --git a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
index 87b0787afe0e..62b5c73eee61 100644
--- a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
+++ b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
@@ -257,7 +257,7 @@ $min-height: 1.5em;
position: absolute;
top: $gap-y;
left: $gap-x;
- overflow-y: scroll;
+ overflow-y: auto;
line-height: 1.2;
width: calc(100% - $gap-x - $gap-x);
diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml
index 91f2ab3184a3..c8c55017a0de 100644
--- a/client/src/utils/navigation/navigation.yml
+++ b/client/src/utils/navigation/navigation.yml
@@ -698,13 +698,15 @@ workflow_editor:
comment:
selectors:
_: ".workflow-editor-comment"
- text: ".text-workflow-comment"
- markdown: ".markdown-workflow-comment"
+ text_comment: ".text-workflow-comment"
+ text_inner: ".text-workflow-comment span"
+ markdown_comment: ".markdown-workflow-comment"
markdown_rendered: ".markdown-workflow-comment .rendered-markdown"
- frame: ".frame-workflow-comment"
+ frame_comment: ".frame-workflow-comment"
frame_title: ".frame-workflow-comment .frame-comment-header"
- freehand: ".freehand-workflow-comment"
+ freehand_comment: ".freehand-workflow-comment"
freehand_path: ".freehand-workflow-comment path"
+ delete: "button[title='Delete comment']"
selectors:
canvas_body: '#workflow-canvas'
edit_annotation: '#workflow-annotation'
diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py
index af04ac19281e..806e8705a701 100644
--- a/lib/galaxy/selenium/navigates_galaxy.py
+++ b/lib/galaxy/selenium/navigates_galaxy.py
@@ -17,8 +17,10 @@
Any,
cast,
Dict,
+ List,
NamedTuple,
Optional,
+ Tuple,
Union,
)
@@ -26,6 +28,7 @@
import yaml
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
+from selenium.webdriver.remote.webelement import WebElement
from galaxy.navigation.components import (
Component,
@@ -2246,6 +2249,27 @@ def tutorial_mode_activate(self):
self.wait_for_and_click_selector(search_selector)
self.wait_for_selector_visible("#gtn-screen")
+ def mouse_drag(
+ self,
+ from_element: WebElement,
+ to_element: Optional[WebElement] = None,
+ from_offset=(0, 0),
+ to_offset=(0, 0),
+ via_offsets: Optional[List[Tuple[int, int]]] = None,
+ ):
+ chain = self.action_chains().move_to_element(from_element).move_by_offset(*from_offset)
+ chain = chain.click_and_hold().pause(self.wait_length(self.wait_types.UX_RENDER))
+
+ if via_offsets is not None:
+ for offset in via_offsets:
+ chain = chain.move_by_offset(*offset).pause(self.wait_length(self.wait_types.UX_RENDER))
+
+ if to_element is not None:
+ chain = chain.move_to_element(to_element)
+
+ chain = chain.move_by_offset(*to_offset).pause(self.wait_length(self.wait_types.UX_RENDER)).release()
+ chain.perform()
+
class NotLoggedInException(SeleniumTimeoutException):
def __init__(self, timeout_exception, user_info, dom_message):
diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py
index d57474e39048..cd284ea6c2ba 100644
--- a/lib/galaxy_test/selenium/test_workflow_editor.py
+++ b/lib/galaxy_test/selenium/test_workflow_editor.py
@@ -4,7 +4,9 @@
import pytest
import yaml
from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.remote.webelement import WebElement
from seletools.actions import drag_and_drop
from galaxy_test.base.workflow_fixtures import (
@@ -1084,6 +1086,134 @@ def test_map_over_output_indicator(self):
self.workflow_editor_destroy_connection("filter#how|filter_source")
self.assert_node_output_is("filter#output_filtered", "list")
+ @selenium_test
+ def test_editor_place_comments(self):
+ editor = self.components.workflow_editor
+ self.workflow_create_new(annotation="simple workflow")
+ self.sleep_for(self.wait_types.UX_RENDER)
+
+ tool_bar = editor.tool_bar._.wait_for_visible()
+
+ # select text comment tool use all options and set font size to 2
+ editor.tool_bar.tool(tool="text_comment").wait_for_and_click()
+ editor.tool_bar.toggle_bold.wait_for_and_click()
+ editor.tool_bar.toggle_italic.wait_for_and_click()
+ editor.tool_bar.colour(colour="pink").wait_for_and_click()
+ editor.tool_bar.font_size.wait_for_and_click()
+ self.action_chains().send_keys(Keys.LEFT * 5).send_keys(Keys.RIGHT).perform()
+
+ # place text comment
+ self.mouse_drag(from_element=tool_bar, to_offset=(400, 110))
+
+ self.action_chains().send_keys("Hello World").perform()
+
+ # check if all options were applied
+ comment_content: WebElement = editor.comment.text_inner.wait_for_visible()
+ assert comment_content.text == "Hello World"
+ assert "bold" in comment_content.get_attribute("class")
+ assert "italic" in comment_content.get_attribute("class")
+
+ # check for correct size
+ width, height = self.get_element_size(editor.comment._.wait_for_visible())
+
+ assert width == 400
+ assert height == 110
+
+ editor.comment.text_comment.wait_for_and_click()
+ editor.comment.delete.wait_for_and_click()
+ editor.comment.text_comment.wait_for_absent()
+
+ # place and test markdown comment
+ editor.tool_bar.tool(tool="markdown_comment").wait_for_and_click()
+ editor.tool_bar.colour(colour="lime").wait_for_and_click()
+ self.mouse_drag(from_element=tool_bar, from_offset=(100, 100), to_offset=(200, 220))
+ self.action_chains().send_keys("# Hello World").perform()
+
+ editor.tool_bar.tool(tool="pointer").wait_for_and_click()
+
+ markdown_comment_content: WebElement = editor.comment.markdown_rendered.wait_for_visible()
+ assert markdown_comment_content.text == "Hello World"
+ assert markdown_comment_content.find_element(By.TAG_NAME, "h2") is not None
+
+ width, height = self.get_element_size(editor.comment._.wait_for_visible())
+
+ assert width == 200
+ assert height == 220
+
+ editor.comment.markdown_rendered.wait_for_and_click()
+ editor.comment.delete.wait_for_and_click()
+ editor.comment.markdown_comment.wait_for_absent()
+
+ # place and test frame comment
+ editor.tool_bar.tool(tool="frame_comment").wait_for_and_click()
+ editor.tool_bar.colour(colour="blue").wait_for_and_click()
+ self.mouse_drag(from_element=tool_bar, from_offset=(10, 10), to_offset=(400, 300))
+ self.action_chains().send_keys("My Frame").perform()
+
+ title: WebElement = editor.comment.frame_title.wait_for_visible()
+ assert title.text == "My Frame"
+
+ width, height = self.get_element_size(editor.comment._.wait_for_visible())
+
+ assert width == 400
+ assert height == 300
+
+ editor.comment.frame_comment.wait_for_and_click()
+ editor.comment.delete.wait_for_and_click()
+ editor.comment.frame_comment.wait_for_absent()
+
+ # test freehand and eraser
+ editor.tool_bar.tool(tool="freehand_pen").wait_for_and_click()
+ editor.tool_bar.colour(colour="green").wait_for_and_click()
+ editor.tool_bar.line_thickness.wait_for_and_click()
+ self.action_chains().send_keys(Keys.RIGHT * 20).perform()
+
+ editor.tool_bar.smoothing.wait_for_and_click()
+ self.action_chains().send_keys(Keys.RIGHT * 10).perform()
+
+ self.mouse_drag(from_element=tool_bar, from_offset=(100, 100), to_offset=(200, 200))
+
+ editor.comment.freehand_comment.wait_for_visible()
+
+ editor.tool_bar.colour(colour="black").wait_for_and_click()
+ editor.tool_bar.line_thickness.wait_for_and_click()
+ self.action_chains().send_keys(Keys.LEFT * 20).perform()
+ self.mouse_drag(from_element=tool_bar, from_offset=(300, 300), via_offsets=[(100, 200)], to_offset=(-200, 30))
+
+ # test bulk remove freehand
+ editor.tool_bar.remove_freehand.wait_for_and_click()
+ editor.comment.freehand_comment.wait_for_absent()
+
+ # place another freehand comment and test eraser
+ editor.tool_bar.line_thickness.wait_for_and_click()
+ self.action_chains().send_keys(Keys.RIGHT * 20).perform()
+ editor.tool_bar.colour(colour="orange").wait_for_and_click()
+
+ self.mouse_drag(from_element=tool_bar, from_offset=(100, 100), to_offset=(200, 200))
+
+ freehand_comment_a: WebElement = editor.comment.freehand_comment.wait_for_visible()
+
+ # delete by clicking
+ editor.tool_bar.tool(tool="freehand_eraser").wait_for_and_click()
+ self.action_chains().move_to_element(freehand_comment_a).click().perform()
+
+ editor.comment.freehand_comment.wait_for_absent()
+
+ # delete by dragging
+ editor.tool_bar.tool(tool="freehand_pen").wait_for_and_click()
+ editor.tool_bar.colour(colour="yellow").wait_for_and_click()
+
+ self.mouse_drag(from_element=tool_bar, from_offset=(100, 100), to_offset=(200, 200))
+
+ freehand_comment_b: WebElement = editor.comment.freehand_comment.wait_for_visible()
+
+ editor.tool_bar.tool(tool="freehand_eraser").wait_for_and_click()
+ self.mouse_drag(
+ from_element=freehand_comment_b, from_offset=(100, -100), via_offsets=[(-100, 100)], to_offset=(-100, 100)
+ )
+
+ editor.comment.freehand_comment.wait_for_absent()
+
@selenium_test
def test_editor_snapping(self):
editor = self.components.workflow_editor
@@ -1124,14 +1254,26 @@ def test_editor_snapping(self):
def get_node_position(self, label: str):
node = self.components.workflow_editor.node._(label=label).wait_for_present()
- left: str = node.value_of_css_property("left")
- top: str = node.value_of_css_property("top")
+ return self.get_element_position(node)
+
+ def get_element_position(self, element: WebElement):
+ left = element.value_of_css_property("left")
+ top = element.value_of_css_property("top")
left_stripped = "".join(char for char in left if char.isdigit())
top_stripped = "".join(char for char in top if char.isdigit())
return (int(left_stripped), int(top_stripped))
+ def get_element_size(self, element: WebElement):
+ width = element.value_of_css_property("width")
+ height = element.value_of_css_property("height")
+
+ width_stripped = "".join(char for char in width if char.isdigit())
+ height_stripped = "".join(char for char in height if char.isdigit())
+
+ return (int(width_stripped), int(height_stripped))
+
def assert_node_output_is(self, label: str, output_type: str, subcollection_type: Optional[str] = None):
editor = self.components.workflow_editor
node_label, output_name = label.split("#")
From 0237e7e3eed415d171bdeef1892a06a26c1ff6cc Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 4 Oct 2023 09:44:39 +0200
Subject: [PATCH 21/43] add extra styling for rendered markdown
---
.../Workflow/Editor/Comments/MarkdownComment.vue | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
index 62b5c73eee61..397f091af3bd 100644
--- a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
+++ b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
@@ -303,6 +303,20 @@ $min-height: 1.5em;
&:deep(p) {
margin-bottom: 0.5rem;
}
+
+ &:deep(blockquote) {
+ padding-left: 0.5rem;
+ border-left: 2px solid var(--primary-colour);
+ margin-bottom: 0.5rem;
+
+ p {
+ margin-bottom: 0;
+ }
+ }
+
+ &:deep(a) {
+ text-decoration: underline;
+ }
}
.resize-container {
From b46d0c8f8cb4d66066263d8d176f304dfadf5b52 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Tue, 17 Oct 2023 16:17:30 +0200
Subject: [PATCH 22/43] draw freehand comments on minimap
---
.../Workflow/Editor/WorkflowMinimap.vue | 87 ++++------------
.../Workflow/Editor/modules/canvasDraw.ts | 98 +++++++++++++++++++
2 files changed, 116 insertions(+), 69 deletions(-)
create mode 100644 client/src/components/Workflow/Editor/modules/canvasDraw.ts
diff --git a/client/src/components/Workflow/Editor/WorkflowMinimap.vue b/client/src/components/Workflow/Editor/WorkflowMinimap.vue
index bcaadbe09c92..ddb8864e9912 100644
--- a/client/src/components/Workflow/Editor/WorkflowMinimap.vue
+++ b/client/src/components/Workflow/Editor/WorkflowMinimap.vue
@@ -8,13 +8,14 @@ import { useAnimationFrameThrottle } from "@/composables/throttle";
import { useWorkflowStores } from "@/composables/workflowStores";
import type {
FrameWorkflowComment,
+ FreehandWorkflowComment,
MarkdownWorkflowComment,
TextWorkflowComment,
WorkflowComment,
} from "@/stores/workflowEditorCommentStore";
import type { Step, Steps } from "@/stores/workflowStepStore";
-import * as commentColours from "./Comments/colours";
+import { drawBoxComments, drawFreehandComments, drawSteps } from "./modules/canvasDraw";
import { AxisAlignedBoundingBox, Transform } from "./modules/geometry";
const props = defineProps<{
@@ -29,7 +30,8 @@ const emit = defineEmits<{
(e: "moveTo", position: { x: number; y: number }): void;
}>();
-const { stateStore } = useWorkflowStores();
+const { stateStore, commentStore } = useWorkflowStores();
+const { isJustCreated } = commentStore;
/** reference to the main canvas element */
const canvas: Ref = ref(null);
@@ -157,9 +159,11 @@ function renderMinimap() {
// apply global to local transform
canvasTransform.applyToContext(ctx);
+ // sort comments by type
const frameComments: FrameWorkflowComment[] = [];
const markdownComments: MarkdownWorkflowComment[] = [];
const textComments: TextWorkflowComment[] = [];
+ const freehandComments: FreehandWorkflowComment[] = [];
props.comments.forEach((comment) => {
if (comment.type === "frame") {
@@ -168,15 +172,19 @@ function renderMinimap() {
markdownComments.push(comment);
} else if (comment.type === "text") {
textComments.push(comment);
+ } else {
+ if (!isJustCreated(comment.id)) {
+ freehandComments.push(comment);
+ }
}
});
+ // sort steps by error state
const allSteps = Object.values(props.steps);
const okSteps: Step[] = [];
const errorSteps: Step[] = [];
let selectedStep: Step | undefined;
- // sort steps into different arrays
allSteps.forEach((step) => {
if (stateStore.activeNodeId === step.id) {
selectedStep = step;
@@ -190,74 +198,15 @@ function renderMinimap() {
});
// draw rects
-
- ctx.lineWidth = 2 / canvasTransform.scaleX;
- frameComments.forEach((comment) => {
- ctx.beginPath();
-
- if (comment.colour !== "none") {
- ctx.fillStyle = commentColours.brighterColours[comment.colour];
- ctx.strokeStyle = commentColours.colours[comment.colour];
- } else {
- ctx.fillStyle = "rgba(0, 0, 0, 0)";
- ctx.strokeStyle = colors.node;
- }
-
- ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
- ctx.fill();
- ctx.stroke();
- });
-
+ drawBoxComments(ctx, frameComments, 2 / canvasTransform.scaleX, colors.node, true);
ctx.fillStyle = "white";
- markdownComments.forEach((comment) => {
- ctx.beginPath();
-
- if (comment.colour !== "none") {
- ctx.strokeStyle = commentColours.colours[comment.colour];
- } else {
- ctx.strokeStyle = colors.node;
- }
-
- ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
- ctx.fill();
- ctx.stroke();
- });
-
- ctx.lineWidth = 1 / canvasTransform.scaleX;
- textComments.forEach((comment) => {
- ctx.beginPath();
+ drawBoxComments(ctx, markdownComments, 2 / canvasTransform.scaleX, colors.node);
+ ctx.fillStyle = "rgba(0, 0, 0, 0)";
+ drawBoxComments(ctx, textComments, 1 / canvasTransform.scaleX, colors.node);
+ drawSteps(ctx, okSteps, colors.node, stateStore);
+ drawSteps(ctx, errorSteps, colors.error, stateStore);
- if (comment.colour !== "none") {
- ctx.strokeStyle = commentColours.brightColours[comment.colour];
- } else {
- ctx.strokeStyle = colors.node;
- }
-
- ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
- ctx.stroke();
- });
-
- ctx.beginPath();
- ctx.fillStyle = colors.node;
- okSteps.forEach((step) => {
- const rect = stateStore.stepPosition[step.id];
-
- if (rect) {
- ctx.rect(step.position!.left, step.position!.top, rect.width, rect.height);
- }
- });
- ctx.fill();
-
- ctx.beginPath();
- ctx.fillStyle = colors.error;
- errorSteps.forEach((step) => {
- const rect = stateStore.stepPosition[step.id];
-
- if (rect) {
- ctx.rect(step.position!.left, step.position!.top, rect.width, rect.height);
- }
- });
- ctx.fill();
+ drawFreehandComments(ctx, freehandComments, colors.node);
// draw selected
if (selectedStep) {
diff --git a/client/src/components/Workflow/Editor/modules/canvasDraw.ts b/client/src/components/Workflow/Editor/modules/canvasDraw.ts
new file mode 100644
index 000000000000..fe62c1dc63e7
--- /dev/null
+++ b/client/src/components/Workflow/Editor/modules/canvasDraw.ts
@@ -0,0 +1,98 @@
+import { curveCatmullRom, line } from "d3";
+
+import * as commentColours from "@/components/Workflow/Editor/Comments/colours";
+import type {
+ FrameWorkflowComment,
+ FreehandWorkflowComment,
+ MarkdownWorkflowComment,
+ TextWorkflowComment,
+} from "@/stores/workflowEditorCommentStore";
+import type { useWorkflowStateStore } from "@/stores/workflowEditorStateStore";
+import type { Step } from "@/stores/workflowStepStore";
+
+export function drawBoxComments(
+ ctx: CanvasRenderingContext2D,
+ comments: FrameWorkflowComment[] | TextWorkflowComment[] | MarkdownWorkflowComment[],
+ lineWidth: number,
+ defaultColour: string,
+ colourFill = false
+) {
+ ctx.lineWidth = lineWidth;
+
+ if (colourFill) {
+ comments.forEach((comment) => {
+ ctx.beginPath();
+
+ if (comment.colour !== "none") {
+ ctx.fillStyle = commentColours.brighterColours[comment.colour];
+ ctx.strokeStyle = commentColours.colours[comment.colour];
+ } else {
+ ctx.fillStyle = "rgba(0, 0, 0, 0)";
+ ctx.strokeStyle = defaultColour;
+ }
+
+ ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
+ ctx.fill();
+ ctx.stroke();
+ });
+ } else {
+ comments.forEach((comment) => {
+ ctx.beginPath();
+
+ if (comment.colour !== "none") {
+ ctx.strokeStyle = commentColours.colours[comment.colour];
+ } else {
+ ctx.strokeStyle = defaultColour;
+ }
+
+ ctx.rect(comment.position[0], comment.position[1], comment.size[0], comment.size[1]);
+ ctx.fill();
+ ctx.stroke();
+ });
+ }
+}
+
+export function drawSteps(
+ ctx: CanvasRenderingContext2D,
+ steps: Step[],
+ colour: string,
+ stateStore: ReturnType
+) {
+ ctx.beginPath();
+ ctx.fillStyle = colour;
+ steps.forEach((step) => {
+ const rect = stateStore.stepPosition[step.id];
+
+ if (rect) {
+ ctx.rect(step.position!.left, step.position!.top, rect.width, rect.height);
+ }
+ });
+ ctx.fill();
+}
+
+export function drawFreehandComments(
+ ctx: CanvasRenderingContext2D,
+ comments: FreehandWorkflowComment[],
+ defaultColour: string
+) {
+ const catmullRom = line().curve(curveCatmullRom).context(ctx);
+
+ comments.forEach((comment) => {
+ if (comment.colour === "none") {
+ ctx.strokeStyle = defaultColour;
+ } else {
+ ctx.strokeStyle = commentColours.colours[comment.colour];
+ }
+
+ ctx.lineWidth = comment.data.thickness;
+
+ const line = comment.data.line.map(
+ (vec) => [vec[0] + comment.position[0], vec[1] + comment.position[1]] as [number, number]
+ );
+
+ ctx.beginPath();
+ catmullRom(line);
+ ctx.stroke();
+ ctx.closePath();
+ });
+}
From 51c82282406b0a859fea1910c8375316f2c09611 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 18 Oct 2023 15:03:08 +0200
Subject: [PATCH 23/43] pan relative to mouse position instead of bounds this
greatly improves panning for large objects
---
.../components/Workflow/Editor/Draggable.vue | 14 +++++++++++---
.../Workflow/Editor/DraggablePan.vue | 19 +++++++++----------
2 files changed, 20 insertions(+), 13 deletions(-)
diff --git a/client/src/components/Workflow/Editor/Draggable.vue b/client/src/components/Workflow/Editor/Draggable.vue
index 80e6d71c8b75..bc55f8f14a0a 100644
--- a/client/src/components/Workflow/Editor/Draggable.vue
+++ b/client/src/components/Workflow/Editor/Draggable.vue
@@ -34,7 +34,14 @@ const props = defineProps({
},
});
-const emit = defineEmits(["mousedown", "mouseup", "move", "dragstart", "start", "stop"]);
+const emit = defineEmits<{
+ (e: "mousedown", event: DragEvent): void;
+ (e: "mouseup", event: DragEvent): void;
+ (e: "move", position: Position & { unscaled: Position & Size }, event: DragEvent): void;
+ (e: "dragstart", event: DragEvent): void;
+ (e: "start"): void;
+ (e: "stop"): void;
+}>();
let dragImg: HTMLImageElement | null = null;
const draggable = ref();
@@ -42,6 +49,7 @@ const size = reactive(useAnimationFrameSize(draggable));
const transform: Ref | undefined = inject("transform");
type Position = { x: number; y: number };
+type Size = { width: number; height: number };
const { throttle } = useAnimationFrameThrottle();
@@ -108,14 +116,14 @@ const onMove = (position: Position, event: DragEvent) => {
});
};
-const onEnd = (_position: Position, _event: DragEvent) => {
+const onEnd = (_position: Position, event: DragEvent) => {
if (dragImg) {
document.body.removeChild(dragImg);
dragImg = null;
}
dragging = false;
- emit("mouseup");
+ emit("mouseup", event);
emit("stop");
};
diff --git a/client/src/components/Workflow/Editor/DraggablePan.vue b/client/src/components/Workflow/Editor/DraggablePan.vue
index 147a50e1105a..c9a72bfac19b 100644
--- a/client/src/components/Workflow/Editor/DraggablePan.vue
+++ b/client/src/components/Workflow/Editor/DraggablePan.vue
@@ -31,13 +31,16 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ panMargin: {
+ type: Number,
+ default: 60,
+ },
});
-type Size = { width: number; height: number };
type Position = { x: number; y: number };
type MovePosition = Position & {
- unscaled: Position & Size;
+ unscaled: Position;
};
const emit = defineEmits<{
@@ -55,8 +58,6 @@ let movePosition: MovePosition = {
unscaled: {
x: 0,
y: 0,
- width: 0,
- height: 0,
},
};
@@ -100,13 +101,11 @@ function onMove(position: MovePosition, event: MouseEvent) {
return clampedDelta;
};
- const unscaled = position.unscaled;
-
- const deltaLeft = unscaled.x - props.rootOffset.left;
- const deltaRight = props.rootOffset.right - unscaled.x - unscaled.width * props.scale;
+ const deltaLeft = event.pageX - props.rootOffset.left - props.panMargin;
+ const deltaRight = props.rootOffset.right - event.pageX - props.panMargin;
- const deltaTop = unscaled.y - props.rootOffset.top;
- const deltaBottom = props.rootOffset.bottom - unscaled.y - unscaled.height * props.scale;
+ const deltaTop = event.pageY - props.rootOffset.top - props.panMargin;
+ const deltaBottom = props.rootOffset.bottom - event.pageY - props.panMargin;
if (deltaLeft < 0) {
panBy.x = deltaSpeed(deltaLeft);
From 310a57779234fdff334b97b4bfdecaa01c887433 Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 18 Oct 2023 15:07:35 +0200
Subject: [PATCH 24/43] make button menu always appear on top
---
.../src/components/Workflow/Editor/Comments/FrameComment.vue | 4 ++++
.../components/Workflow/Editor/Comments/MarkdownComment.vue | 5 ++++-
.../src/components/Workflow/Editor/Comments/TextComment.vue | 5 ++++-
.../components/Workflow/Editor/Comments/_buttonGroup.scss | 1 +
4 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Workflow/Editor/Comments/FrameComment.vue b/client/src/components/Workflow/Editor/Comments/FrameComment.vue
index 0579d3d6ee35..e942cdd7a410 100644
--- a/client/src/components/Workflow/Editor/Comments/FrameComment.vue
+++ b/client/src/components/Workflow/Editor/Comments/FrameComment.vue
@@ -276,6 +276,10 @@ onMounted(() => {
width: 100%;
height: 100%;
+ .resize-container {
+ z-index: 0;
+ }
+
&:focus-within {
.resize-container {
resize: both;
diff --git a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
index 397f091af3bd..6cb9984933c4 100644
--- a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
+++ b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue
@@ -195,7 +195,10 @@ $min-height: 1.5em;
position: absolute;
width: 100%;
height: 100%;
- z-index: 50;
+
+ .resize-container {
+ z-index: 50;
+ }
&:focus-within {
.resize-container {
diff --git a/client/src/components/Workflow/Editor/Comments/TextComment.vue b/client/src/components/Workflow/Editor/Comments/TextComment.vue
index 1c75e5e87558..2b73f227c908 100644
--- a/client/src/components/Workflow/Editor/Comments/TextComment.vue
+++ b/client/src/components/Workflow/Editor/Comments/TextComment.vue
@@ -262,7 +262,10 @@ onMounted(() => {
position: absolute;
width: 100%;
height: 100%;
- z-index: 200;
+
+ .resize-container {
+ z-index: 200;
+ }
&:focus-within {
.style-buttons {
diff --git a/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss b/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss
index 74ebb906c068..a6ad04d84a91 100644
--- a/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss
+++ b/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss
@@ -4,6 +4,7 @@
position: absolute;
top: -2rem;
right: 0;
+ z-index: 10000;
.button {
padding: 0;
From 81783e4273c6dd6269ab228ef32fcaf4a6597fde Mon Sep 17 00:00:00 2001
From: Laila Los <44241786+ElectronicBlueberry@users.noreply.github.com>
Date: Wed, 18 Oct 2023 15:11:05 +0200
Subject: [PATCH 25/43] make colour selector always appear on top
---
.../components/Workflow/Editor/Comments/ColourSelector.vue | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Workflow/Editor/Comments/ColourSelector.vue b/client/src/components/Workflow/Editor/Comments/ColourSelector.vue
index 2faa53447830..660bf75d7f84 100644
--- a/client/src/components/Workflow/Editor/Comments/ColourSelector.vue
+++ b/client/src/components/Workflow/Editor/Comments/ColourSelector.vue
@@ -17,7 +17,7 @@ function onClickColour(colour: WorkflowCommentColour) {
-
+