diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 68b3fbe315ba..33ad7e890842 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -41,7 +41,14 @@ const baseRules = { "vuejs-accessibility/form-control-has-label": "warn", "vuejs-accessibility/heading-has-content": "error", "vuejs-accessibility/iframe-has-title": "error", - "vuejs-accessibility/label-has-for": "warn", + "vuejs-accessibility/label-has-for": [ + "warn", + { + required: { + some: ["nesting", "id"], + }, + }, + ], "vuejs-accessibility/mouse-events-have-key-events": "warn", "vuejs-accessibility/no-autofocus": "error", "vuejs-accessibility/no-static-element-interactions": "warn", diff --git a/client/icons/all-icons.md b/client/icons/all-icons.md index d240632c9cd6..9549333a3944 100644 --- a/client/icons/all-icons.md +++ b/client/icons/all-icons.md @@ -7,5 +7,7 @@ This file is auto-generated by `build_icons.js`. Manual changes will be lost! --- - `` +- `` +- `` --- diff --git a/client/icons/galaxy/textLarger.duotone.svg b/client/icons/galaxy/textLarger.duotone.svg new file mode 100755 index 000000000000..ad1bfc9904f4 --- /dev/null +++ b/client/icons/galaxy/textLarger.duotone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/icons/galaxy/textSmaller.duotone.svg b/client/icons/galaxy/textSmaller.duotone.svg new file mode 100755 index 000000000000..58369da0cdb8 --- /dev/null +++ b/client/icons/galaxy/textSmaller.duotone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/package.json b/client/package.json index dcb5b09c60f7..07cb75d90e38 100644 --- a/client/package.json +++ b/client/package.json @@ -89,6 +89,7 @@ "rxjs": "^7.8.1", "rxjs-spy": "^8.0.2", "rxjs-spy-devtools-plugin": "^0.0.4", + "simplify-js": "^1.2.4", "slugify": "^1.6.6", "stream-browserify": "^3.0.0", "timers-browserify": "^2.0.12", @@ -141,6 +142,7 @@ "@pinia/testing": "0.1.3", "@testing-library/jest-dom": "^6.1.4", "@types/d3": "^7.4.2", + "@types/dompurify": "^3.0.2", "@types/jquery": "^3.5.24", "@types/lodash": "^4.14.200", "@types/lodash.isequal": "^4.5.7", 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/ColourSelector.vue b/client/src/components/Workflow/Editor/Comments/ColourSelector.vue new file mode 100644 index 000000000000..660bf75d7f84 --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/ColourSelector.vue @@ -0,0 +1,100 @@ + + + + + onClickColour('none')"> + onClickColour(name)"> + + + + diff --git a/client/src/components/Workflow/Editor/Comments/FrameComment.vue b/client/src/components/Workflow/Editor/Comments/FrameComment.vue new file mode 100644 index 000000000000..208a1ee5b5ee --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/FrameComment.vue @@ -0,0 +1,422 @@ + + + + + + + emit('pan-by', p)" /> + + + + + + + + + + + + + (showColourSelector = !showColourSelector)"> + + + emit('remove')"> + + + + + + + + + diff --git a/client/src/components/Workflow/Editor/Comments/FreehandComment.vue b/client/src/components/Workflow/Editor/Comments/FreehandComment.vue new file mode 100644 index 000000000000..cf8ce6b2355b --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/FreehandComment.vue @@ -0,0 +1,96 @@ + + + + + + + + + diff --git a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue new file mode 100644 index 000000000000..6de34d7bb7f4 --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue @@ -0,0 +1,365 @@ + + + + + + emit('pan-by', p)" /> + + Markdown Input + + + + + + + + (showColourSelector = !showColourSelector)"> + + + emit('remove')"> + + + + + + + + + diff --git a/client/src/components/Workflow/Editor/Comments/TextComment.vue b/client/src/components/Workflow/Editor/Comments/TextComment.vue new file mode 100644 index 000000000000..2b73f227c908 --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/TextComment.vue @@ -0,0 +1,353 @@ + + + + + + + emit('pan-by', p)" /> + + + + + + + B + + + I + + (showColourSelector = !showColourSelector)"> + + + + + + + + + emit('remove')"> + + + + + + + + + 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 }); + }); +}); diff --git a/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue b/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue new file mode 100644 index 000000000000..827d7a90573f --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue @@ -0,0 +1,108 @@ + + + + + + + + + + + + diff --git a/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss b/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss new file mode 100644 index 000000000000..a6ad04d84a91 --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/_buttonGroup.scss @@ -0,0 +1,24 @@ +@import "theme/blue.scss"; + +@mixin button-group-style { + position: absolute; + top: -2rem; + right: 0; + z-index: 10000; + + .button { + padding: 0; + height: 1.5rem; + width: 1.5rem; + } + + &:deep(.btn-outline-primary) { + &:not(.active) { + background-color: $workflow-editor-bg; + } + + &:hover { + background-color: $brand-primary; + } + } +} diff --git a/client/src/components/Workflow/Editor/Comments/colours.ts b/client/src/components/Workflow/Editor/Comments/colours.ts new file mode 100644 index 000000000000..5c72648e1e2d --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/colours.ts @@ -0,0 +1,51 @@ +import { Hsluv } from "hsluv"; + +export const colours = { + black: "#000", + blue: "#004cec", + turquoise: "#00bbd9", + green: "#319400", + lime: "#68c000", + orange: "#f48400", + yellow: "#fdbd0b", + red: "#e31920", + pink: "#fb00a6", +} as const; + +export type Colour = keyof typeof colours; +export type HexColour = `#${string}`; + +export const brightColours = (() => { + const brighter: Record = {}; + const converter = new Hsluv(); + + Object.entries(colours).forEach(([name, colour]) => { + converter.hex = colour; + converter.hexToHsluv(); + converter.hsluv_l += (100 - converter.hsluv_l) * 0.5; + converter.hsluvToHex(); + brighter[name] = converter.hex; + }); + return brighter as Record; +})(); + +export const brighterColours = (() => { + const brighter: Record = {}; + const converter = new Hsluv(); + + Object.entries(colours).forEach(([name, colour]) => { + converter.hex = colour; + converter.hexToHsluv(); + converter.hsluv_l += (100 - converter.hsluv_l) * 0.95; + converter.hsluvToHex(); + brighter[name] = converter.hex; + }); + return brighter as Record; +})(); + +export const darkenedColours = { + ...colours, + turquoise: "#00a6c0" as const, + lime: "#5eae00" as const, + yellow: "#e9ad00" as const, +}; diff --git a/client/src/components/Workflow/Editor/Comments/useResizable.ts b/client/src/components/Workflow/Editor/Comments/useResizable.ts new file mode 100644 index 000000000000..58dab7a963a1 --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/useResizable.ts @@ -0,0 +1,58 @@ +import { useEventListener } from "@vueuse/core"; +import { type Ref, watch } from "vue"; + +import { useWorkflowStores } from "@/composables/workflowStores"; + +import { vecSnap } from "../modules/geometry"; + +/** + * Common functionality required for handling a user resizable element. + * `resize: both` needs to be set on the elements style, this composable + * will not apply it. + * + * @param target Element to attach to + * @param sizeControl External override for the size. Will override the user resize when set + * @param onResized Function to run when the target element receives a mouseup event, and it's size changed + */ +export function useResizable( + target: Ref, + sizeControl: Ref<[number, number]>, + onResized: (size: [number, number]) => void +) { + // override user resize if size changes externally + watch( + () => sizeControl.value, + ([width, height]) => { + const element = target.value; + + if (element) { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + } + } + ); + + let prevWidth = sizeControl.value[0]; + let prevHeight = sizeControl.value[1]; + + const { toolbarStore } = useWorkflowStores(); + useEventListener(target, "mouseup", () => { + const element = target.value; + + if (element) { + const width = element.offsetWidth; + const height = element.offsetHeight; + + if (prevWidth !== width || prevHeight !== height) { + if (toolbarStore.snapActive) { + onResized(vecSnap([width, height], toolbarStore.snapDistance)); + } else { + onResized([width, height]); + } + + prevWidth = width; + prevHeight = height; + } + } + }); +} diff --git a/client/src/components/Workflow/Editor/Comments/utilities.ts b/client/src/components/Workflow/Editor/Comments/utilities.ts new file mode 100644 index 000000000000..2f3f803c031d --- /dev/null +++ b/client/src/components/Workflow/Editor/Comments/utilities.ts @@ -0,0 +1,22 @@ +export function selectAllText(element: HTMLElement) { + element.focus(); + + if (element instanceof HTMLTextAreaElement) { + element.select(); + } else { + const range = document.createRange(); + range.selectNodeContents(element); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + deselectOnClick(); + } +} + +function deselectOnClick() { + const listener = () => { + window.getSelection()?.removeAllRanges(); + window.removeEventListener("click", listener, { capture: true }); + }; + + window.addEventListener("click", listener, { capture: true }); +} diff --git a/client/src/components/Workflow/Editor/Draggable.vue b/client/src/components/Workflow/Editor/Draggable.vue index 79634948bdcd..bc55f8f14a0a 100644 --- a/client/src/components/Workflow/Editor/Draggable.vue +++ b/client/src/components/Workflow/Editor/Draggable.vue @@ -1,10 +1,12 @@ @@ -155,6 +159,7 @@ function onMouseUp(e: MouseEvent) { :disabled="disabled" @move="onMove" @mouseup="onMouseUp" + @start="onStart" v-on="$listeners"> diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index b28414828809..e2c6460a79ef 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -235,8 +235,9 @@ export default { setup(props, { emit }) { const { datatypes, datatypesMapper, datatypesMapperLoading } = useDatatypesMapper(); - const { connectionStore, stepStore, stateStore } = provideScopedWorkflowStores(props.id); + const { connectionStore, stepStore, stateStore, commentStore } = provideScopedWorkflowStores(props.id); + const { comments } = storeToRefs(commentStore); const { getStepIndex, steps } = storeToRefs(stepStore); const { activeNodeId } = storeToRefs(stateStore); const activeStep = computed(() => { @@ -249,7 +250,11 @@ export default { const hasChanges = ref(false); const hasInvalidConnections = computed(() => Object.keys(connectionStore.invalidConnections).length > 0); - stepStore.$subscribe((mutation, state) => { + stepStore.$subscribe((_mutation, _state) => { + hasChanges.value = true; + }); + + commentStore.$subscribe((_mutation, _state) => { hasChanges.value = true; }); @@ -257,17 +262,21 @@ export default { connectionStore.$reset(); stepStore.$reset(); stateStore.$reset(); + commentStore.$reset(); } + onUnmounted(() => { resetStores(); emit("update:confirmation", false); }); + return { connectionStore, hasChanges, hasInvalidConnections, stepStore, steps, + comments, nodeIndex: getStepIndex, datatypes, activeStep, @@ -505,7 +514,7 @@ export default { formData.append("workflow_name", rename_name); formData.append("workflow_annotation", rename_annotation); formData.append("from_tool_form", true); - formData.append("workflow_data", JSON.stringify(toSimple(this))); + formData.append("workflow_data", JSON.stringify(toSimple(this.id, this))); axios .post(`${getAppRoot()}workflow/save_workflow_as`, formData) @@ -732,7 +741,7 @@ export default { left: 1rem; bottom: 1rem; cursor: pointer; - z-index: 1002; + z-index: 2000; } .workflow-center { diff --git a/client/src/components/Workflow/Editor/Tools/InputCatcher.vue b/client/src/components/Workflow/Editor/Tools/InputCatcher.vue new file mode 100644 index 000000000000..738b34997aa1 --- /dev/null +++ b/client/src/components/Workflow/Editor/Tools/InputCatcher.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/client/src/components/Workflow/Editor/Tools/ToolBar.vue b/client/src/components/Workflow/Editor/Tools/ToolBar.vue new file mode 100644 index 000000000000..e95c25f8aaae --- /dev/null +++ b/client/src/components/Workflow/Editor/Tools/ToolBar.vue @@ -0,0 +1,400 @@ + + + + + + + + + + + + + + + + onCommentToolClick('textComment')"> + T + + onCommentToolClick('markdownComment')"> + + + onCommentToolClick('frameComment')"> + + + + + + onCommentToolClick('freehandComment')"> + + + onCommentToolClick('freehandEraser')"> + + + + + + + + Snapping Distance + {{ toolbarStore.snapDistance }} pixels + + + + + + + + Bold + + + Italic + + + + + + (commentOptions.colour = colour)" /> + + + + + Text Size + {{ commentOptions.textSize }}00% + + + + + + + Size + {{ commentOptions.lineThickness }} pixels + + + + + + + Smoothing + {{ commentOptions.smoothing }} + + + + + + + Remove all + + + + + + + diff --git a/client/src/components/Workflow/Editor/Tools/useToolLogic.ts b/client/src/components/Workflow/Editor/Tools/useToolLogic.ts new file mode 100644 index 000000000000..af7a84d4d4c2 --- /dev/null +++ b/client/src/components/Workflow/Editor/Tools/useToolLogic.ts @@ -0,0 +1,166 @@ +import simplify from "simplify-js"; +import { watch } from "vue"; + +import type { BaseWorkflowComment, WorkflowCommentStore } from "@/stores/workflowEditorCommentStore"; +import { type WorkflowEditorToolbarStore } from "@/stores/workflowEditorToolbarStore"; +import { assertDefined } from "@/utils/assertions"; +import { match } from "@/utils/utils"; + +import { vecMax, vecMin, vecReduceFigures, vecSnap, vecSubtract, type Vector } from "../modules/geometry"; + +export function useToolLogic(toolbarStore: WorkflowEditorToolbarStore, commentStore: WorkflowCommentStore) { + let comment: BaseWorkflowComment | null = null; + let start: Vector | null = null; + + const { commentOptions } = toolbarStore; + + watch( + () => toolbarStore.currentTool, + () => { + if (comment?.type === "freehand") { + finalizeFreehandComment(comment); + } else { + comment = null; + } + } + ); + + toolbarStore.onInputCatcherEvent("pointerdown", ({ position }) => { + start = position; + + if (toolbarStore.currentTool === "freehandEraser") { + return; + } + + if (comment?.type === "freehand" || !comment) { + const baseComment = { + id: commentStore.highestCommentId + 1, + position: start, + size: [0, 0] as [number, number], + colour: commentOptions.colour, + }; + + comment = match(toolbarStore.currentTool, { + textComment: () => ({ + ...baseComment, + type: "text", + data: { + size: commentOptions.textSize, + text: "Enter Text", + bold: commentOptions.bold || undefined, + italic: commentOptions.italic || undefined, + } as BaseWorkflowComment["data"], + }), + markdownComment: () => ({ + ...baseComment, + type: "markdown", + data: { + text: "*Enter Text*", + }, + }), + frameComment: () => ({ + ...baseComment, + type: "frame", + data: { + title: "Frame", + }, + }), + freehandComment: () => ({ + ...baseComment, + type: "freehand", + data: { + thickness: commentOptions.lineThickness, + line: [position], + }, + }), + pointer: () => { + throw new Error("Tool logic should not be active when pointer tool is selected"); + }, + }); + + commentStore.createComment(comment); + } + }); + + toolbarStore.onInputCatcherEvent("pointermove", ({ position }) => { + if (toolbarStore.currentTool === "freehandEraser") { + return; + } + + if (comment && start) { + if (comment.type === "freehand") { + commentStore.addPoint(comment.id, position); + } else { + positionComment(start, position, comment); + } + } + }); + + toolbarStore.onInputCatcherEvent("pointerup", () => { + if (toolbarStore.currentTool === "freehandEraser") { + return; + } else if (comment?.type === "freehand") { + finalizeFreehandComment(comment); + } else if (toolbarStore.currentTool !== "freehandComment") { + toolbarStore.currentTool = "pointer"; + } + + comment = null; + }); + + toolbarStore.onInputCatcherEvent("pointerleave", () => { + if (comment?.type === "freehand") { + finalizeFreehandComment(comment); + comment = null; + } + }); + + toolbarStore.onInputCatcherEvent("temporarilyDisabled", () => { + if (comment?.type === "freehand") { + finalizeFreehandComment(comment); + comment = null; + } + }); + + const finalizeFreehandComment = (comment: BaseWorkflowComment) => { + const freehandComment = commentStore.commentsRecord[comment.id]; + assertDefined(freehandComment); + if (freehandComment.type !== "freehand") { + throw new Error("Comment is not of type freehandComment"); + } + + // smooth + const xyLine = freehandComment.data.line.map((point) => ({ x: point[0], y: point[1] })); + const simpleLine = simplify(xyLine, commentOptions.smoothing).map((point) => [ + point.x, + point.y, + ]) as Array; + + // 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/WorkflowEdges.vue b/client/src/components/Workflow/Editor/WorkflowEdges.vue index be9ff1d89b34..b6e577ad8691 100644 --- a/client/src/components/Workflow/Editor/WorkflowEdges.vue +++ b/client/src/components/Workflow/Editor/WorkflowEdges.vue @@ -71,5 +71,7 @@ function id(connection: Connection) { top: 0; position: absolute; transform-origin: 0 0; + z-index: 80; + pointer-events: none; } diff --git a/client/src/components/Workflow/Editor/WorkflowGraph.vue b/client/src/components/Workflow/Editor/WorkflowGraph.vue index f72ce29e6de5..1af61b9fe7db 100644 --- a/client/src/components/Workflow/Editor/WorkflowGraph.vue +++ b/client/src/components/Workflow/Editor/WorkflowGraph.vue @@ -1,12 +1,14 @@ + + + = 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); @@ -154,6 +169,9 @@ watchEffect(() => { const canvasStyle = computed(() => { return { transform: `translate(${transform.value.x}px, ${transform.value.y}px) scale(${transform.value.k})` }; }); + +const { commentStore } = useWorkflowStores(); +const { comments } = storeToRefs(commentStore); 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(); + }); +} diff --git a/client/src/components/Workflow/Editor/modules/geometry.ts b/client/src/components/Workflow/Editor/modules/geometry.ts index b462f6e308f1..eee7fc3d56be 100644 --- a/client/src/components/Workflow/Editor/modules/geometry.ts +++ b/client/src/components/Workflow/Editor/modules/geometry.ts @@ -98,6 +98,16 @@ export class AxisAlignedBoundingBox implements Rectangle { return false; } } + + /** check if other rectangle fits into this one */ + contains(other: Rectangle) { + return ( + this.x <= other.x && + this.y <= other.y && + this.endX >= other.x + other.width && + this.endY >= other.y + other.height + ); + } } /* Format @@ -200,3 +210,33 @@ export class Transform { return this.matrix[3]; } } + +/** returns a vector constructed of both vectors smaller coordinates */ +export function vecMin(a: Vector, b: Vector): Vector { + return [Math.min(a[0], b[0]), Math.min(a[1], b[1])]; +} + +/** returns a vector constructed of both vectors larger coordinates */ +export function vecMax(a: Vector, b: Vector): Vector { + return [Math.max(a[0], b[0]), Math.max(a[1], b[1])]; +} + +/** returns a vector snapped to the specified distance */ +export function vecSnap(a: Vector, snap: number): Vector { + return [Math.round(a[0] / snap) * snap, Math.round(a[1] / snap) * snap]; +} + +/** subtracts vector b from vector a */ +export function vecSubtract(a: Vector, b: Vector): Vector { + return [a[0] - b[0], a[1] - b[1]]; +} + +export function vecAdd(a: Vector, b: Vector): Vector { + return [a[0] + b[0], a[1] + b[1]]; +} + +export function vecReduceFigures(a: Vector, significantFigures = 1): Vector { + const factor = Math.pow(10, significantFigures); + + return [Math.round(a[0] * factor) / factor, Math.round(a[1] * factor) / factor]; +} diff --git a/client/src/components/Workflow/Editor/modules/model.ts b/client/src/components/Workflow/Editor/modules/model.ts index 36c5eff4e1ad..4f43288fe0b8 100644 --- a/client/src/components/Workflow/Editor/modules/model.ts +++ b/client/src/components/Workflow/Editor/modules/model.ts @@ -1,3 +1,4 @@ +import { useWorkflowCommentStore, type WorkflowComment } from "@/stores/workflowEditorCommentStore"; import { type ConnectionOutputLink, type Steps, useWorkflowStepStore } from "@/stores/workflowStepStore"; interface Workflow { @@ -8,6 +9,7 @@ interface Workflow { version: number; report: any; steps: Steps; + comments: WorkflowComment[]; } /** @@ -25,11 +27,14 @@ export async function fromSimple( defaultPosition = { top: 0, left: 0 } ) { const stepStore = useWorkflowStepStore(id); - const stepIdOffset = stepStore.getStepIndex + 1; - Object.values(data.steps).forEach((step) => { - // If workflow being copied into another, wipe UUID and let - // Galaxy assign new ones. - if (appendData) { + const commentStore = useWorkflowCommentStore(id); + + // If workflow being copied into another, wipe UUID and let + // Galaxy assign new ones. + if (appendData) { + const stepIdOffset = stepStore.getStepIndex + 1; + + Object.values(data.steps).forEach((step) => { delete step.uuid; if (!step.position) { // Should only happen for manually authored editor content, @@ -54,19 +59,33 @@ export async function fromSimple( }); } }); - } - }); + }); + + data.comments.forEach((comment, index) => { + comment.id = commentStore.highestCommentId + 1 + index; + }); + } + Object.values(data.steps).map((step) => { stepStore.addStep(step); }); + + commentStore.addComments(data.comments, [defaultPosition.left, defaultPosition.top]); } -export function toSimple(workflow: Workflow) { +export function toSimple(id: string, workflow: Workflow): Omit { const steps = workflow.steps; const report = workflow.report; const license = workflow.license; const creator = workflow.creator; const annotation = workflow.annotation; const name = workflow.name; - return { steps, report, license, creator, annotation, name }; + + const commentStore = useWorkflowCommentStore(id); + commentStore.resolveCommentsInFrames(); + commentStore.resolveStepsInFrames(); + + const comments = workflow.comments.filter((comment) => !(comment.type === "text" && comment.data.text === "")); + + return { steps, report, license, creator, annotation, name, comments }; } diff --git a/client/src/components/Workflow/Editor/modules/services.js b/client/src/components/Workflow/Editor/modules/services.js index e0bb9c2c4e1c..65905c5d7849 100644 --- a/client/src/components/Workflow/Editor/modules/services.js +++ b/client/src/components/Workflow/Editor/modules/services.js @@ -54,7 +54,7 @@ export async function loadWorkflow({ id, version = null }) { export async function saveWorkflow(workflow) { if (workflow.hasChanges) { try { - const requestData = { workflow: toSimple(workflow), from_tool_form: true }; + const requestData = { workflow: toSimple(workflow.id, workflow), from_tool_form: true }; const { data } = await axios.put(`${getAppRoot()}api/workflows/${workflow.id}`, requestData); workflow.name = data.name; workflow.hasChanges = false; diff --git a/client/src/composables/markdown.js b/client/src/composables/markdown.js deleted file mode 100644 index aec145cf2203..000000000000 --- a/client/src/composables/markdown.js +++ /dev/null @@ -1,43 +0,0 @@ -import MarkdownIt from "markdown-it"; -import { readonly } from "vue"; - -const mdEngine = MarkdownIt(); - -/** - * Adds a rule to open all links in a new page. - * https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer - */ -function addRuleOpenLinksInNewPage(engine) { - var defaultRender = - engine.renderer.rules.link_open || - function (tokens, idx, options, env, self) { - return self.renderToken(tokens, idx, options); - }; - engine.renderer.rules.link_open = function (tokens, idx, options, env, self) { - var aIndex = tokens[idx].attrIndex("target"); - if (aIndex < 0) { - tokens[idx].attrPush(["target", "_blank"]); - } else { - tokens[idx].attrs[aIndex][1] = "_blank"; - } - return defaultRender(tokens, idx, options, env, self); - }; -} - -/** Composable for rendering Markdown strings. */ -export function useMarkdown(options = {}) { - if (options.openLinksInNewPage) { - addRuleOpenLinksInNewPage(mdEngine); - } - - function renderMarkdown(markdown) { - return mdEngine.render(markdown); - } - - return { - /** Render markdown string into html. */ - renderMarkdown, - /** The full Markdown parser/renderer engine for advanced use cases. */ - markdownEngine: readonly(mdEngine), - }; -} diff --git a/client/src/composables/markdown.ts b/client/src/composables/markdown.ts new file mode 100644 index 000000000000..a6659f60d404 --- /dev/null +++ b/client/src/composables/markdown.ts @@ -0,0 +1,102 @@ +import MarkdownIt from "markdown-it"; +import type Token from "markdown-it/lib/token"; +import { readonly } from "vue"; + +/** + * Adds a rule to open all links in a new page. + * https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer + */ +function addRuleOpenLinksInNewPage(engine: MarkdownIt) { + const defaultRender = + engine.renderer.rules.link_open || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + engine.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + + if (token) { + const aIndex = token.attrIndex("target"); + + if (aIndex && aIndex < 0) { + token.attrPush(["target", "_blank"]); + } else { + token.attrs![aIndex]![1] = "_blank"; + } + } + + return defaultRender(tokens, idx, options, env, self); + }; +} + +function addRuleHeadingIncreaseLevel(engine: MarkdownIt, increaseBy: number) { + const defaultOpen = + engine.renderer.rules.heading_open || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + const defaultClose = + engine.renderer.rules.heading_close || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + const increaseHeadingLevel = (token: Token) => { + const level = parseInt(token.tag[1] ?? "1"); + token.tag = `h${level + increaseBy}`; + }; + + engine.renderer.rules.heading_open = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + + if (token) { + increaseHeadingLevel(token); + } + + return defaultOpen(tokens, idx, options, env, self); + }; + + engine.renderer.rules.heading_close = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + + if (token) { + increaseHeadingLevel(token); + } + + return defaultClose(tokens, idx, options, env, self); + }; +} + +interface UseMarkdownOptions { + openLinksInNewPage?: boolean; + increaseHeadingLevelBy?: number; +} + +type RawMarkdown = string; +type HTMLString = string; + +/** Composable for rendering Markdown strings. */ +export function useMarkdown(options: UseMarkdownOptions = {}) { + const mdEngine = MarkdownIt(); + + if (options.openLinksInNewPage) { + addRuleOpenLinksInNewPage(mdEngine); + } + + if (options.increaseHeadingLevelBy) { + addRuleHeadingIncreaseLevel(mdEngine, options.increaseHeadingLevelBy); + } + + function renderMarkdown(markdown: RawMarkdown): HTMLString { + return mdEngine.render(markdown); + } + + return { + /** Render markdown string into html. */ + renderMarkdown, + /** The full Markdown parser/renderer engine for advanced use cases. */ + markdownEngine: readonly(mdEngine), + }; +} diff --git a/client/src/composables/workflowStores.ts b/client/src/composables/workflowStores.ts index 40893840e5b0..25d8e1c3f5c6 100644 --- a/client/src/composables/workflowStores.ts +++ b/client/src/composables/workflowStores.ts @@ -1,7 +1,9 @@ import { inject, onScopeDispose, provide } from "vue"; import { useConnectionStore } from "@/stores/workflowConnectionStore"; +import { useWorkflowCommentStore } from "@/stores/workflowEditorCommentStore"; import { useWorkflowStateStore } from "@/stores/workflowEditorStateStore"; +import { useWorkflowEditorToolbarStore } from "@/stores/workflowEditorToolbarStore"; import { useWorkflowStepStore } from "@/stores/workflowStepStore"; /** @@ -19,17 +21,23 @@ export function provideScopedWorkflowStores(workflowId: string) { const connectionStore = useConnectionStore(workflowId); const stateStore = useWorkflowStateStore(workflowId); const stepStore = useWorkflowStepStore(workflowId); + const commentStore = useWorkflowCommentStore(workflowId); + const toolbarStore = useWorkflowEditorToolbarStore(workflowId); onScopeDispose(() => { connectionStore.$dispose(); stateStore.$dispose(); stepStore.$dispose(); + commentStore.$dispose(); + toolbarStore.$dispose(); }); return { connectionStore, stateStore, stepStore, + commentStore, + toolbarStore, }; } @@ -47,17 +55,21 @@ export function useWorkflowStores() { if (typeof workflowId !== "string") { throw new Error( - "Workflow ID not provided by parent component. Use `setupWorkflowStores` on a parent component." + "Workflow ID not provided by parent component. Use `provideScopedWorkflowStores` on a parent component." ); } const connectionStore = useConnectionStore(workflowId); const stateStore = useWorkflowStateStore(workflowId); const stepStore = useWorkflowStepStore(workflowId); + const commentStore = useWorkflowCommentStore(workflowId); + const toolbarStore = useWorkflowEditorToolbarStore(workflowId); return { connectionStore, stateStore, stepStore, + commentStore, + toolbarStore, }; } diff --git a/client/src/stores/workflowEditorCommentStore.test.ts b/client/src/stores/workflowEditorCommentStore.test.ts new file mode 100644 index 000000000000..ad433b494e73 --- /dev/null +++ b/client/src/stores/workflowEditorCommentStore.test.ts @@ -0,0 +1,206 @@ +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: [100, 100], +}; + +const textComment: TextWorkflowComment = { + type: "text", + colour: "none", + data: { + size: 1, + text: "Hello World", + }, + id: 1, + position: [100, 200], + size: [50, 50], +}; + +const markdownComment: MarkdownWorkflowComment = { + type: "markdown", + colour: "none", + data: { + text: "# Hello World", + }, + id: 2, + position: [200, 100], + size: [100, 100], +}; + +const frameComment: FrameWorkflowComment = { + type: "frame", + colour: "none", + data: { + title: "My Frame", + }, + id: 3, + position: [0, 0], + size: [500, 500], +}; + +const frameCommentTwo: FrameWorkflowComment = { + type: "frame", + colour: "none", + data: { + title: "My Frame", + }, + id: 4, + position: [50, 50], + size: [180, 180], +}; + +jest.mock("@/stores/workflowStepStore", () => ({ + useWorkflowStepStore: jest.fn(() => ({ + steps: { + 0: { + id: 0, + position: { left: 0, top: 0 }, + }, + 1: { + id: 1, + position: { left: 100, top: 100 }, + }, + }, + })), +})); + +jest.mock("@/stores/workflowEditorStateStore", () => ({ + useWorkflowStateStore: jest.fn(() => ({ + stepPosition: { + 0: { + width: 200, + height: 200, + }, + 1: { + width: 10, + height: 10, + }, + }, + })), +})); + +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); + }); + + it("determines which comments are in what frames", () => { + const commentStore = useWorkflowCommentStore("mock-id"); + commentStore.addComments([freehandComment, textComment, markdownComment, frameComment, frameCommentTwo]); + + commentStore.resolveCommentsInFrames(); + + const frame = commentStore.commentsRecord[frameComment.id]; + const frameTwo = commentStore.commentsRecord[frameCommentTwo.id]; + + expect(frameTwo?.child_comments?.length).toBe(1); + expect(frameTwo?.child_comments).toContain(freehandComment.id); + + expect(frame?.child_comments?.length).toBe(3); + expect(frame?.child_comments).toContain(frameCommentTwo.id); + expect(frame?.child_comments).toContain(markdownComment.id); + expect(frame?.child_comments).toContain(textComment.id); + expect(frame?.child_comments).not.toContain(freehandComment.id); + }); + + it("determines which steps are in what frames", () => { + const commentStore = useWorkflowCommentStore("mock-id"); + commentStore.addComments([frameComment, frameCommentTwo]); + + commentStore.resolveStepsInFrames(); + + const frame = commentStore.commentsRecord[frameComment.id]; + const frameTwo = commentStore.commentsRecord[frameCommentTwo.id]; + + expect(frame?.child_steps).toContain(0); + expect(frameTwo?.child_steps).toContain(1); + + expect(frame?.child_steps).not.toContain(1); + expect(frameTwo?.child_steps).not.toContain(0); + }); +}); diff --git a/client/src/stores/workflowEditorCommentStore.ts b/client/src/stores/workflowEditorCommentStore.ts new file mode 100644 index 000000000000..a88c184ca9a1 --- /dev/null +++ b/client/src/stores/workflowEditorCommentStore.ts @@ -0,0 +1,308 @@ +import { defineStore } from "pinia"; +import { computed, del, ref, set } from "vue"; + +import type { Colour } from "@/components/Workflow/Editor/Comments/colours"; +import { + AxisAlignedBoundingBox, + type Rectangle, + vecAdd, + vecMax, + vecMin, + vecReduceFigures, + vecSubtract, + Vector, +} from "@/components/Workflow/Editor/modules/geometry"; +import { assertDefined } from "@/utils/assertions"; +import { hasKeys, match } from "@/utils/utils"; + +import { useWorkflowStateStore } from "./workflowEditorStateStore"; +import { Step, useWorkflowStepStore } from "./workflowStepStore"; + +export type WorkflowCommentColour = Colour | "none"; + +export interface BaseWorkflowComment { + id: number; + type: string; + colour: WorkflowCommentColour; + position: [number, number]; + size: [number, number]; + data: unknown; + child_comments?: number[]; + child_steps?: number[]; +} + +export interface TextWorkflowComment extends BaseWorkflowComment { + type: "text"; + data: { + bold?: true; + italic?: true; + size: number; + text: string; + }; +} + +export interface FrameWorkflowComment extends BaseWorkflowComment { + type: "frame"; + data: { + title: string; + }; +} + +export interface MarkdownWorkflowComment extends BaseWorkflowComment { + type: "markdown"; + data: { + text: string; + }; +} + +export interface FreehandWorkflowComment extends BaseWorkflowComment { + type: "freehand"; + data: { + thickness: number; + line: Vector[]; + }; +} + +export type WorkflowComment = + | TextWorkflowComment + | FrameWorkflowComment + | MarkdownWorkflowComment + | FreehandWorkflowComment; + +interface CommentsMetadata { + justCreated?: boolean; +} + +function assertCommentDataValid( + 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 "${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>({}); + const localCommentsMetadata = ref>({}); + + const comments = computed(() => Object.values(commentsRecord.value)); + + function $reset() { + commentsRecord.value = {}; + localCommentsMetadata.value = {}; + } + + const addComments = (commentsArray: WorkflowComment[], defaultPosition: [number, number] = [0, 0]) => { + commentsArray.forEach((comment) => { + const newComment = structuredClone(comment); + newComment.position[0] += defaultPosition[0]; + newComment.position[1] += defaultPosition[1]; + + set(commentsRecord.value, newComment.id, newComment); + }); + }; + + const highestCommentId = computed(() => comments.value[comments.value.length - 1]?.id ?? -1); + + const isJustCreated = computed(() => (id: number) => localCommentsMetadata.value[id]?.justCreated ?? false); + + const getComment = computed(() => (id: number) => { + const comment = commentsRecord.value[id]; + assertDefined(comment); + return comment; + }); + + function changePosition(id: number, position: [number, number]) { + const comment = getComment.value(id); + set(comment, "position", vecReduceFigures(position)); + } + + function changeSize(id: number, size: [number, number]) { + const comment = getComment.value(id); + set(comment, "size", vecReduceFigures(size)); + } + + function changeData(id: number, data: unknown) { + const comment = getComment.value(id); + assertCommentDataValid(comment.type, data); + set(comment, "data", data); + } + + function changeColour(id: number, colour: WorkflowCommentColour) { + const comment = getComment.value(id); + 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); + } + }); + } + + function commentToRectangle(comment: WorkflowComment): Rectangle { + return { + x: comment.position[0], + y: comment.position[1], + width: comment.size[0], + height: comment.size[1], + }; + } + + /** Calculates which comments are within frames and attaches that information to the parent comments */ + function resolveCommentsInFrames() { + // reverse to give frames on top of other frames higher precedence + const frameComments = comments.value.filter((comment) => comment.type === "frame").reverse(); + let candidates = [...comments.value]; + + frameComments.forEach((frame) => { + const bounds = new AxisAlignedBoundingBox(); + bounds.fitRectangle(commentToRectangle(frame)); + frame.child_comments = []; + + // remove when matched, so each comment can only be linked to one frame + candidates = candidates.flatMap((comment) => { + const rect: Rectangle = commentToRectangle(comment); + + if (comment !== frame && bounds.contains(rect)) { + // push id and remove from candidates when in bounds + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + frame.child_comments!.push(comment.id); + return []; + } else { + // otherwise do nothing and keep in list + return [comment]; + } + }); + }); + } + + const stateStore = useWorkflowStateStore(workflowId); + + function stepToRectangle(step: Step): Rectangle | null { + const rect = stateStore.stepPosition[step.id]; + + if (rect && step.position) { + return { + x: step.position.left, + y: step.position.top, + width: rect.width, + height: rect.height, + }; + } else { + return null; + } + } + + const stepStore = useWorkflowStepStore(workflowId); + + /** Calculates which steps are within frames and attaches that information to the parent comments */ + function resolveStepsInFrames() { + // reverse to give frames on top of other frames higher precedence + const frameComments = comments.value.filter((comment) => comment.type === "frame").reverse(); + let candidates = [...Object.values(stepStore.steps)]; + + frameComments.forEach((frame) => { + const bounds = new AxisAlignedBoundingBox(); + bounds.fitRectangle(commentToRectangle(frame)); + frame.child_steps = []; + + // remove when matched, so each step can only be linked to one frame + candidates = candidates.flatMap((step) => { + const rect = stepToRectangle(step); + + if (rect && bounds.contains(rect)) { + // push id and remove from candidates when in bounds + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + frame.child_steps!.push(step.id); + return []; + } else { + // otherwise do nothing and keep in list + return [step]; + } + }); + }); + } + + return { + commentsRecord, + comments, + addComments, + highestCommentId, + isJustCreated, + changePosition, + changeSize, + changeData, + changeColour, + addPoint, + deleteComment, + createComment, + clearJustCreated, + deleteFreehandComments, + resolveCommentsInFrames, + resolveStepsInFrames, + $reset, + }; + })(); +}; 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 new file mode 100644 index 000000000000..5b51506e538b --- /dev/null +++ b/client/src/stores/workflowEditorToolbarStore.ts @@ -0,0 +1,98 @@ +import { useMagicKeys } from "@vueuse/core"; +import { defineStore } from "pinia"; +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" + | "pointerleave" + | "temporarilyDisabled"; + +interface InputCatcherEventListener { + type: InputCatcherEventType; + callback: (event: InputCatcherEvent) => void; +} + +export interface InputCatcherEvent { + type: InputCatcherEventType; + position: [number, number]; +} + +export type WorkflowEditorToolbarStore = ReturnType; + +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/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index 81307f23930e..fc90bd332e07 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -682,6 +682,31 @@ 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_comment: ".text-workflow-comment" + text_inner: ".text-workflow-comment span" + markdown_comment: ".markdown-workflow-comment" + markdown_rendered: ".markdown-workflow-comment .rendered-markdown" + frame_comment: ".frame-workflow-comment" + frame_title: ".frame-workflow-comment .frame-comment-header" + 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/client/src/utils/utils.ts b/client/src/utils/utils.ts index 9fab7a3c5164..d4916291084d 100644 --- a/client/src/utils/utils.ts +++ b/client/src/utils/utils.ts @@ -387,6 +387,52 @@ export function mergeObjectListsById = { + [_Case in T]: () => R; +}; + +/** + * Alternative to `switch` statement. + * Unlike `switch` it is exhaustive and allows for returning a value. + * + * @param key A key with the type of a Union of possible keys + * @param matcher An object with a key for every possible match and a function as value, which will be ran if a match occurs + * @returns The ran functions return value + * + * @example + * ```ts + * type literal = "a" | "b"; + * const thing = "a" as literal; + * + * const result = match(thing, { + * a: () => 1, + * b: () => 2, + * }); + * + * result === 1; + * ``` + */ +export function match(key: T, matcher: MatchObject): R { + return matcher[key](); +} + +/** + * Checks whether or not an object contains all supplied keys. + * + * @param object Object to check + * @param keys Array of all keys to check for + * @returns if all keys were found + */ +export function hasKeys(object: unknown, keys: string[]) { + if (typeof object === "object" && object !== null) { + let valid = true; + keys.forEach((key) => (valid = valid && key in object)); + return valid; + } else { + return false; + } +} + export default { cssLoadFile, get, diff --git a/client/yarn.lock b/client/yarn.lock index 3635aa620230..1e4a5ff24658 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2293,6 +2293,13 @@ "@types/d3-transition" "*" "@types/d3-zoom" "*" +"@types/dompurify@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.2.tgz#c1cd33a475bc49c43c2a7900e41028e2136a4553" + integrity sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ== + dependencies: + "@types/trusted-types" "*" + "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz" @@ -2542,6 +2549,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/trusted-types@*": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.5.tgz#5cac7e7df3275bb95f79594f192d97da3b4fd5fe" + integrity sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA== + "@types/underscore@^1.11.12": version "1.11.12" resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.11.12.tgz#e97d50e6d11c6da234ca1836024502dbd5587d1f" @@ -10357,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" diff --git a/lib/galaxy/managers/workflows.py b/lib/galaxy/managers/workflows.py index fd8e64e6ed6d..bbb83e316a2e 100644 --- a/lib/galaxy/managers/workflows.py +++ b/lib/galaxy/managers/workflows.py @@ -796,6 +796,30 @@ def _workflow_from_raw_description( workflow.has_cycles = True workflow.steps = steps + + comments: List[model.WorkflowComment] = [] + comments_by_external_id: Dict[str, model.WorkflowComment] = {} + for comment_dict in data.get("comments", []): + comment = model.WorkflowComment.from_dict(comment_dict) + comments.append(comment) + external_id = comment_dict.get("id") + if external_id: + comments_by_external_id[external_id] = comment + + workflow.comments = comments + + # populate parent_comment + for comment, comment_dict in zip(comments, data.get("comments", [])): + for step_external_id in comment_dict.get("child_steps", []): + child_step = steps_by_external_id.get(step_external_id) + if child_step: + child_step.parent_comment = comment + + for comment_external_id in comment_dict.get("child_comments", []): + child_comment = comments_by_external_id.get(comment_external_id) + if child_comment: + child_comment.parent_comment = comment + # we can't reorder subworkflows, as step connections would become invalid if not is_subworkflow: # Order the steps if possible @@ -1119,6 +1143,7 @@ def _workflow_to_dict_editor(self, trans, stored, workflow, tooltip=True, is_sub data["creator"] = workflow.creator_metadata data["source_metadata"] = workflow.source_metadata data["annotation"] = self.get_item_annotation_str(trans.sa_session, trans.user, stored) or "" + data["comments"] = [comment.to_dict() for comment in workflow.comments] output_label_index = set() input_step_types = set(workflow.input_step_types) @@ -1363,6 +1388,7 @@ def _workflow_to_dict_export(self, trans, stored=None, workflow=None, internal=F data["uuid"] = str(workflow.uuid) steps: Dict[int, Dict[str, Any]] = {} data["steps"] = steps + data["comments"] = [comment.to_dict() for comment in workflow.comments] if workflow.reports_config: data["report"] = workflow.reports_config if workflow.creator_metadata: diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index b261a9b8337d..3e6280c5ad79 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -141,6 +141,7 @@ DatasetValidatedState, JobState, ) +from galaxy.schema.workflow.comments import WorkflowCommentModel from galaxy.security import get_permitted_actions from galaxy.security.idencoding import IdEncodingHelper from galaxy.security.validate_user_input import validate_password_str @@ -7383,6 +7384,13 @@ class Workflow(Base, Dictifiable, RepresentById): cascade="all, delete-orphan", lazy=False, ) + comments: List["WorkflowComment"] = relationship( + "WorkflowComment", + back_populates="workflow", + primaryjoin=(lambda: Workflow.id == WorkflowComment.workflow_id), # type: ignore[has-type] + cascade="all, delete-orphan", + lazy=False, + ) parent_workflow_steps = relationship( "WorkflowStep", primaryjoin=(lambda: Workflow.id == WorkflowStep.subworkflow_id), # type: ignore[has-type] @@ -7549,6 +7557,13 @@ class WorkflowStep(Base, RepresentById): uuid = Column(UUIDType) label = Column(Unicode(255)) temp_input_connections: Optional[InputConnDictType] + parent_comment_id = Column(Integer, ForeignKey("workflow_comment.id"), nullable=True) + + parent_comment = relationship( + "WorkflowComment", + primaryjoin=(lambda: WorkflowComment.id == WorkflowStep.parent_comment_id), + back_populates="child_steps", + ) subworkflow: Optional[Workflow] = relationship( "Workflow", @@ -7958,6 +7973,82 @@ def _serialize(self, id_encoder, serialization_options): ) +class WorkflowComment(Base, RepresentById): + """ + WorkflowComment represents an in-editor comment which is no associated to any WorkflowStep. + It is purely decorative, and should not influence how a workflow is ran. + """ + + __tablename__ = "workflow_comment" + + id = Column(Integer, primary_key=True) + order_index: int = Column(Integer) + workflow_id = Column(Integer, ForeignKey("workflow.id"), index=True, nullable=False) + position = Column(MutableJSONType) + size = Column(JSONType) + type = Column(String(16)) + colour = Column(String(16)) + data = Column(JSONType) + parent_comment_id = Column(Integer, ForeignKey("workflow_comment.id"), nullable=True) + + workflow = relationship( + "Workflow", + primaryjoin=(lambda: Workflow.id == WorkflowComment.workflow_id), + back_populates="comments", + ) + + child_steps: List["WorkflowStep"] = relationship( + "WorkflowStep", + primaryjoin=(lambda: WorkflowStep.parent_comment_id == WorkflowComment.id), + back_populates="parent_comment", + ) + + parent_comment: "WorkflowComment" = relationship( + "WorkflowComment", + primaryjoin=(lambda: WorkflowComment.id == WorkflowComment.parent_comment_id), + back_populates="child_comments", + remote_side=[id], + ) + + child_comments: List["WorkflowComment"] = relationship( + "WorkflowComment", + primaryjoin=(lambda: WorkflowComment.parent_comment_id == WorkflowComment.id), + back_populates="parent_comment", + ) + + def to_dict(self): + comment_dict = { + "id": self.order_index, + "position": self.position, + "size": self.size, + "type": self.type, + "colour": self.colour, + "data": self.data, + } + + if self.child_steps: + comment_dict["child_steps"] = [step.order_index for step in self.child_steps] + + if self.child_comments: + comment_dict["child_comments"] = [comment.order_index for comment in self.child_comments] + + WorkflowCommentModel(__root__=comment_dict) + + return comment_dict + + def from_dict(dict): + WorkflowCommentModel(__root__=dict) + + comment = WorkflowComment() + comment.order_index = dict.get("id", 0) + comment.type = dict.get("type", "text") + comment.position = dict.get("position", None) + comment.size = dict.get("size", None) + comment.colour = dict.get("colour", "none") + comment.data = dict.get("data", None) + return comment + + class StoredWorkflowUserShareAssociation(Base, UserShareAssociation): __tablename__ = "stored_workflow_user_share_connection" diff --git a/lib/galaxy/model/migrations/alembic/versions_gxy/ddbdbc40bdc1_add_workflow_comment_table.py b/lib/galaxy/model/migrations/alembic/versions_gxy/ddbdbc40bdc1_add_workflow_comment_table.py new file mode 100644 index 000000000000..1b297478eac5 --- /dev/null +++ b/lib/galaxy/model/migrations/alembic/versions_gxy/ddbdbc40bdc1_add_workflow_comment_table.py @@ -0,0 +1,61 @@ +"""add workflow_comment table +Revision ID: ddbdbc40bdc1 +Revises: 92fb564c7095 +Create Date: 2023-08-14 13:41:59.442243 +""" +import sqlalchemy as sa + +from galaxy.model.custom_types import ( + JSONType, + MutableJSONType, +) +from galaxy.model.migrations.util import ( + add_column, + create_foreign_key, + create_table, + drop_column, + drop_table, + transaction, +) + +# revision identifiers, used by Alembic. +revision = "ddbdbc40bdc1" +down_revision = "92fb564c7095" +branch_labels = None +depends_on = None + +WORKFLOW_COMMENT_TABLE_NAME = "workflow_comment" +WORKFLOW_STEP_TABLE_NAME = "workflow_step" +PARENT_COMMENT_COLUMN_NAME = "parent_comment_id" + + +def upgrade(): + with transaction(): + create_table( + WORKFLOW_COMMENT_TABLE_NAME, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("order_index", sa.Integer), + sa.Column("workflow_id", sa.Integer, sa.ForeignKey("workflow.id")), + sa.Column("position", MutableJSONType), + sa.Column("size", JSONType), + sa.Column("type", sa.String(16)), + sa.Column("colour", sa.String(16)), + sa.Column("data", JSONType), + sa.Column(PARENT_COMMENT_COLUMN_NAME, sa.Integer, sa.ForeignKey("workflow_comment.id"), nullable=True), + ) + + add_column(WORKFLOW_STEP_TABLE_NAME, sa.Column(PARENT_COMMENT_COLUMN_NAME, sa.Integer, nullable=True)) + + create_foreign_key( + "foreign_key_parent_comment_id", + WORKFLOW_STEP_TABLE_NAME, + WORKFLOW_COMMENT_TABLE_NAME, + [PARENT_COMMENT_COLUMN_NAME], + ["id"], + ) + + +def downgrade(): + with transaction(): + drop_column(WORKFLOW_STEP_TABLE_NAME, PARENT_COMMENT_COLUMN_NAME) + drop_table(WORKFLOW_COMMENT_TABLE_NAME) diff --git a/lib/galaxy/schema/workflow/__init__.py b/lib/galaxy/schema/workflow/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lib/galaxy/schema/workflow/comments.py b/lib/galaxy/schema/workflow/comments.py new file mode 100644 index 000000000000..3e270ace9ed2 --- /dev/null +++ b/lib/galaxy/schema/workflow/comments.py @@ -0,0 +1,74 @@ +from typing import ( + List, + Optional, + Tuple, + Union, +) + +from pydantic import ( + BaseModel, + Field, +) +from typing_extensions import Literal + + +class BaseComment(BaseModel): + id: int = Field(..., description="Unique identifier for this comment. Determined by the comments order") + colour: Literal["none", "black", "blue", "turquoise", "green", "lime", "orange", "yellow", "red", "pink"] = Field( + ..., description="Colour this comment is displayed as. The exact colour hex is determined by the client" + ) + position: Tuple[float, float] = Field(..., description="[x, y] position of this comment in the Workflow") + size: Tuple[float, float] = Field(..., description="[width, height] size of this comment") + + +class TextCommentData(BaseModel): + bold: Optional[bool] = Field(description="If the Comments text is bold. Absent is interpreted as false") + italic: Optional[bool] = Field(description="If the Comments text is italic. Absent is interpreted as false") + size: int = Field(..., description="Relative size (1 -> 100%) of the text compared to the default text sitz") + text: str = Field(..., description="The plaintext text of this comment") + + +class TextComment(BaseComment): + type: Literal["text"] + data: TextCommentData + + +class MarkdownCommentData(BaseModel): + text: str = Field(..., description="The unrendered source Markdown for this Comment") + + +class MarkdownComment(BaseComment): + type: Literal["markdown"] + data: MarkdownCommentData + + +class FrameCommentData(BaseModel): + title: str = Field(..., description="The Frames title") + + +class FrameComment(BaseComment): + type: Literal["frame"] + data: FrameCommentData + child_comments: Optional[List[int]] = Field( + description="A list of ids (see `id`) of all Comments which are encompassed by this Frame" + ) + child_steps: Optional[List[int]] = Field( + description="A list of ids of all Steps (see WorkflowStep.id) which are encompassed by this Frame" + ) + + +class FreehandCommentData(BaseModel): + thickness: int = Field(..., description="Width of the Line in pixels") + line: List[Tuple[float, float]] = Field( + ..., + description="List of [x, y] coordinates determining the unsmoothed line. Smoothing is done client-side using Catmull-Rom", + ) + + +class FreehandComment(BaseComment): + type: Literal["freehand"] + data: FreehandCommentData + + +class WorkflowCommentModel(BaseModel): + __root__: Union[TextComment, MarkdownComment, FrameComment, FreehandComment] 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/webapps/galaxy/api/workflows.py b/lib/galaxy/webapps/galaxy/api/workflows.py index c90de11b7a03..c8f9864d172e 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -530,7 +530,7 @@ def update(self, trans: GalaxyWebTransaction, id, payload, **kwds): with transaction(trans.sa_session): trans.sa_session.commit() - if "steps" in workflow_dict: + if "steps" in workflow_dict or "comments" in workflow_dict: try: workflow_update_options = WorkflowUpdateOptions(**payload) workflow, errors = self.workflow_contents_manager.update_workflow_from_raw_description( diff --git a/lib/galaxy/workflow/steps.py b/lib/galaxy/workflow/steps.py index 1e38574c0c53..f1cb776c9b94 100644 --- a/lib/galaxy/workflow/steps.py +++ b/lib/galaxy/workflow/steps.py @@ -11,24 +11,32 @@ def attach_ordered_steps(workflow): - """Attempt to topologically order steps and attach to workflow. If this - fails - the workflow contains cycles so it mark it as such. + """Attempt to topologically order steps and comments, and attach to workflow. + If ordering Steps fails - the workflow contains cycles so it mark it as such. """ - ordered_steps = order_workflow_steps(workflow.steps) + ordered_steps, ordered_comments = order_workflow_steps(workflow.steps, workflow.comments) workflow.has_cycles = True if ordered_steps: workflow.has_cycles = False workflow.steps = ordered_steps for i, step in enumerate(workflow.steps): step.order_index = i + + workflow.comments = ordered_comments + for i, comment in enumerate(workflow.comments): + comment.order_index = i + return workflow.has_cycles -def order_workflow_steps(steps): +def order_workflow_steps(steps, comments): """ - Perform topological sort of the steps, return ordered or None + Perform topological sort of the steps and comments, + return (ordered steps, ordered comments) or (None, ordered comments) """ position_data_available = bool(steps) + ordered_comments = comments + for step in steps: if step.subworkflow: attach_ordered_steps(step.subworkflow) @@ -38,22 +46,51 @@ def order_workflow_steps(steps): # find minimum left and top values to normalize position min_left = min(step.position["left"] for step in steps) min_top = min(step.position["top"] for step in steps) - # normalize by min_left and min_top + + if comments: + freehand_comments = [] + sortable_comments = [] + + for comment in comments: + if comment.type == "freehand": + freehand_comments.append(comment) + else: + sortable_comments.append(comment) + + # consider comments to find normalization position + min_left_comments = min(comment.position[0] for comment in sortable_comments) + min_top_comments = min(comment.position[1] for comment in sortable_comments) + min_left = min(min_left_comments, min_left) + min_top = min(min_top_comments, min_top) + + # normalize comments by min_left and min_top + for comment_list in [sortable_comments, freehand_comments]: + for comment in comment_list: + comment.position = [comment.position[0] - min_left, comment.position[1] - min_top] + + # order by Euclidean distance to the origin + sortable_comments.sort(key=lambda comment: math.sqrt(comment.position[0] ** 2 + comment.position[1] ** 2)) + + # replace comments list with sorted comments + ordered_comments = freehand_comments + ordered_comments.extend(sortable_comments) + + # normalize steps by min_left and min_top for step in steps: step.position = { "left": step.position["left"] - min_left, "top": step.position["top"] - min_top # other position attributes can be discarded if present } - steps.sort(key=lambda _: _.position["left"] + _.position["top"]) + # order by Euclidean distance to the origin (i.e. pre-normalized (min_left, min_top)) steps.sort(key=lambda _: math.sqrt(_.position["left"] ** 2 + _.position["top"] ** 2)) try: edges = sorted(edgelist_for_workflow_steps(steps)) node_order = topsort(edges) - return [steps[i] for i in node_order] + return ([steps[i] for i in node_order], ordered_comments) except CycleError: - return None + return (None, ordered_comments) def has_cycles(workflow): diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py index 74c02f091e56..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,194 @@ 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 + 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() + + 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("#")