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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@