diff --git a/src/ui/src/builder/BuilderApp.vue b/src/ui/src/builder/BuilderApp.vue index 5d140c125..84f4eec4b 100644 --- a/src/ui/src/builder/BuilderApp.vue +++ b/src/ui/src/builder/BuilderApp.vue @@ -13,7 +13,7 @@ @@ -82,6 +82,7 @@ import BuilderTooltip from "./BuilderTooltip.vue"; import BuilderAsyncLoader from "./BuilderAsyncLoader.vue"; import BuilderPanelSwitcher from "./panels/BuilderPanelSwitcher.vue"; import { WDS_CSS_PROPERTIES } from "@/wds/tokens"; +import { SelectionStatus } from "./builderManager"; const BuilderSettings = defineAsyncComponent({ loader: () => import("./settings/BuilderSettings.vue"), @@ -139,7 +140,7 @@ const { } = useComponentActions(wf, ssbm); const builderMode = computed(() => ssbm.getMode()); -const selectedId = computed(() => ssbm.getSelection()?.componentId); +const selectedId = computed(() => ssbm.firstSelectedId.value); function handleKeydown(ev: KeyboardEvent): void { if (ev.key == "Escape") { @@ -162,9 +163,12 @@ function handleKeydown(ev: KeyboardEvent): void { return; } - if (!ssbm.isSelectionActive()) return; + if (!ssbm.isSingleSelectionActive.value || !ssbm.firstSelectedItem.value) { + return; + } + const { componentId: selectedId, instancePath: selectedInstancePath } = - ssbm.getSelection(); + ssbm.firstSelectedItem.value; if (ev.key == "Delete") { if (!isDeleteAllowed(selectedId)) return; @@ -234,11 +238,20 @@ function handleRendererClick(ev: PointerEvent): void { if (!targetEl) return; const targetId = targetEl.dataset.writerId; const targetInstancePath = targetEl.dataset.writerInstancePath; - if (targetId !== ssbm.getSelectedId()) { - ev.preventDefault(); - ev.stopPropagation(); - ssbm.setSelection(targetId, targetInstancePath, "click"); + + const isAlreadySelected = ssbm.isComponentIdSelected(targetId); + + if ( + isAlreadySelected && + ssbm.selectionStatus.value !== SelectionStatus.Multiple + ) { + return; } + + ev.preventDefault(); + ev.stopPropagation(); + + ssbm.handleSelectionFromEvent(ev, targetId, targetInstancePath, "click"); } const handleRendererDragStart = (ev: DragEvent) => { @@ -251,6 +264,12 @@ const handleRendererDragStart = (ev: DragEvent) => { const componentId = targetEl.dataset.writerId; const { type } = wf.getComponentById(componentId); + // we don't support yet dragginfg multiple components in UI. If drag is starting with multiple selections, we select only one component + if (ssbm.selectionStatus.value === SelectionStatus.Multiple) { + ssbm.setSelection(componentId, undefined, "click"); + ssbm.isSettingsBarCollapsed.value = true; + } + ev.dataTransfer.setData( `application/json;writer=${type},${componentId}`, "{}", diff --git a/src/ui/src/builder/builderManager.spec.ts b/src/ui/src/builder/builderManager.spec.ts new file mode 100644 index 000000000..8b30d4d98 --- /dev/null +++ b/src/ui/src/builder/builderManager.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { generateBuilderManager, SelectionStatus } from "./builderManager"; + +describe(generateBuilderManager.name, () => { + describe("selection", () => { + it("should select an element", () => { + const { + setSelection, + isComponentIdSelected, + selectionStatus, + firstSelectedId, + } = generateBuilderManager(); + + setSelection("componentId", "instancePath", "click"); + + expect(firstSelectedId.value).toBe("componentId"); + expect(isComponentIdSelected("componentId")).toBeTruthy(); + expect(selectionStatus.value).toBe(SelectionStatus.Single); + }); + + it("should select multiple element", () => { + const { + setSelection, + appendSelection, + isComponentIdSelected, + selectionStatus, + firstSelectedId, + } = generateBuilderManager(); + + setSelection("componentId", "instancePath", "click"); + appendSelection("componentId2", "instancePath2", "click"); + + expect(firstSelectedId.value).toBe("componentId"); + expect(isComponentIdSelected("componentId")).toBeTruthy(); + expect(isComponentIdSelected("componentId2")).toBeTruthy(); + expect(selectionStatus.value).toBe(SelectionStatus.Multiple); + }); + + it("should clear the selection an element", () => { + const { + setSelection, + isComponentIdSelected, + selectionStatus, + firstSelectedId, + } = generateBuilderManager(); + + setSelection("componentId", "instancePath", "click"); + setSelection(null); + + expect(firstSelectedId.value).toBeUndefined(); + expect(isComponentIdSelected("componentId")).toBeFalsy(); + expect(selectionStatus.value).toBe(SelectionStatus.None); + }); + + it("should handle click events", () => { + const { + handleSelectionFromEvent, + isComponentIdSelected, + selectionStatus, + } = generateBuilderManager(); + + handleSelectionFromEvent( + { ctrlKey: true } as KeyboardEvent, + "1", + "path", + ); + + expect(selectionStatus.value).toBe(SelectionStatus.Single); + expect(isComponentIdSelected("1")).toBeTruthy(); + + handleSelectionFromEvent( + { ctrlKey: true } as KeyboardEvent, + "2", + "path", + ); + + expect(selectionStatus.value).toBe(SelectionStatus.Multiple); + expect(isComponentIdSelected("1")).toBeTruthy(); + expect(isComponentIdSelected("2")).toBeTruthy(); + + handleSelectionFromEvent( + { ctrlKey: true } as KeyboardEvent, + "2", + "path", + ); + + expect(selectionStatus.value).toBe(SelectionStatus.Single); + expect(isComponentIdSelected("1")).toBeTruthy(); + expect(isComponentIdSelected("2")).toBeFalsy(); + }); + }); +}); diff --git a/src/ui/src/builder/builderManager.ts b/src/ui/src/builder/builderManager.ts index bc7d0a38e..70d207020 100644 --- a/src/ui/src/builder/builderManager.ts +++ b/src/ui/src/builder/builderManager.ts @@ -1,4 +1,4 @@ -import { ref, Ref } from "vue"; +import { computed, ref, Ref } from "vue"; import { Component, ClipboardOperation } from "@/writerTypes"; export const CANDIDATE_CONFIRMATION_DELAY_MS = 1500; @@ -61,13 +61,19 @@ type LogEntry = { type SelectionSource = "click" | "tree" | "log"; +export const enum SelectionStatus { + None = 0, + Single = 1, + Multiple = 2, +} + type State = { mode: "ui" | "workflows" | "preview"; selection: { componentId: Component["id"]; instancePath: string; source: SelectionSource; - }; + }[]; clipboard: { operation: ClipboardOperation; jsonSubtree: string; @@ -82,7 +88,7 @@ type State = { export function generateBuilderManager() { const initState: State = { mode: "ui", - selection: null, + selection: [], clipboard: null, mutationTransactionsSnapshot: { undo: null, @@ -105,14 +111,27 @@ export function generateBuilderManager() { }; const setSelection = ( - componentId: Component["id"], + componentId: Component["id"] | null, instancePath?: string, source?: SelectionSource, ) => { if (componentId === null) { - state.value.selection = null; + state.value.selection = []; return; } + + if (state.value.selection.length !== 0) { + state.value.selection = []; + } + + appendSelection(componentId, instancePath, source); + }; + + const appendSelection = ( + componentId: Component["id"], + instancePath?: string, + source?: SelectionSource, + ) => { let resolvedInstancePath = instancePath; if (typeof resolvedInstancePath == "undefined") { const componentFirstElement: HTMLElement = document.querySelector( @@ -122,25 +141,57 @@ export function generateBuilderManager() { componentFirstElement?.dataset.writerInstancePath; } - state.value.selection = { + state.value.selection.push({ componentId, instancePath: resolvedInstancePath, source, - }; + }); }; - const isSelectionActive = () => { - return state.value.selection !== null; + const handleSelectionFromEvent = ( + ev: MouseEvent | KeyboardEvent, + componentId: Component["id"], + instancePath?: string, + source?: SelectionSource, + ) => { + if (!ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { + return setSelection(componentId, instancePath, source); + } + + if (isComponentIdSelected(componentId)) { + removeSelectedComponentId(componentId); + } else { + appendSelection(componentId, instancePath, source); + } }; - const getSelection = () => { - return state.value.selection; + const isComponentIdSelected = (componentId: string) => { + return state.value.selection.some((s) => s.componentId === componentId); }; - const getSelectedId = () => { - return state.value.selection?.componentId; + const removeSelectedComponentId = (componentId: string) => { + const newSelection = state.value.selection.filter( + (c) => c.componentId !== componentId, + ); + if (newSelection.length === state.value.selection.length) return; + state.value.selection = newSelection; }; + const selectionStatus = computed(() => { + if (state.value.selection.length === 0) return SelectionStatus.None; + if (state.value.selection.length === 1) return SelectionStatus.Single; + return SelectionStatus.Multiple; + }); + const isSingleSelectionActive = computed( + () => selectionStatus.value === SelectionStatus.Single, + ); + + const firstSelectedItem = computed(() => state.value.selection[0]); + + const firstSelectedId = computed( + () => state.value.selection[0]?.componentId, + ); + const setClipboard = (clipboard: State["clipboard"]) => { state.value.clipboard = clipboard; }; @@ -312,10 +363,16 @@ export function generateBuilderManager() { getMode, openPanels: ref(new Set<"code" | "log">()), isSettingsBarCollapsed: ref(false), - isSelectionActive, + isComponentIdSelected, + selectionStatus, + isSingleSelectionActive, + firstSelectedId, + firstSelectedItem, + removeSelectedComponentId, setSelection, - getSelection, - getSelectedId, + appendSelection, + handleSelectionFromEvent, + selection: computed(() => state.value.selection), setClipboard, getClipboard, openMutationTransaction, diff --git a/src/ui/src/builder/settings/BuilderSettings.vue b/src/ui/src/builder/settings/BuilderSettings.vue index e34eb491d..f164b8d50 100644 --- a/src/ui/src/builder/settings/BuilderSettings.vue +++ b/src/ui/src/builder/settings/BuilderSettings.vue @@ -1,10 +1,13 @@