From d173a7a56bb384eec356f1cbdc3c3fa8eebd51a4 Mon Sep 17 00:00:00 2001 From: Mikhail Aheichyk Date: Mon, 15 Jan 2024 14:57:26 +0300 Subject: [PATCH] Add multiselect initial implementation Signed-off-by: Mikhail Aheichyk --- .env.local.default | 1 + docs/configuration.md | 3 + .../ElementOverridesProvider.test.tsx | 50 +++++++ .../ElementOverridesProvider/index.ts | 1 + .../useElementOverrides.ts | 60 ++++++++ .../Whiteboard/Element/ConnectedElement.tsx | 9 +- .../Selection/ElementBarWrapper.tsx | 40 +++--- .../Selection/ElementBorder.tsx | 31 ++--- .../Selection/SelectableElement.tsx | 19 ++- src/components/Whiteboard/WhiteboardHost.tsx | 14 +- src/state/crdt/documents/elements.test.ts | 56 +++++++- src/state/crdt/documents/elements.ts | 44 +++++- src/state/crdt/documents/point.ts | 6 +- src/state/index.ts | 2 + src/state/types.test.ts | 13 +- src/state/types.ts | 35 ++++- src/state/useWhiteboardSlideInstance.test.tsx | 26 +++- src/state/useWhiteboardSlideInstance.tsx | 40 +++++- src/state/whiteboardInstanceImpl.ts | 6 +- src/state/whiteboardSlideInstanceImpl.test.ts | 128 +++++++++++++++++- src/state/whiteboardSlideInstanceImpl.ts | 127 ++++++++++++++--- 21 files changed, 617 insertions(+), 94 deletions(-) create mode 100644 src/components/ElementOverridesProvider/useElementOverrides.ts diff --git a/.env.local.default b/.env.local.default index 7e9ff1c93..7dcabbfa8 100644 --- a/.env.local.default +++ b/.env.local.default @@ -1,2 +1,3 @@ # For more options, see docs/configuration.md REACT_APP_HOME_SERVER_URL=https://matrix-client.matrix.org +REACT_APP_MULTISELECT=false diff --git a/docs/configuration.md b/docs/configuration.md index a070b7c43..d77354908 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,4 +13,7 @@ REACT_APP_HOME_SERVER_URL=https://matrix-client.matrix.org # External link to the documentation that will be shown in the help menu if defined. REACT_APP_HELP_CENTER_URL="https://github.com/nordeck/matrix-neoboard" + +# optional - enables multiselect +REACT_APP_MULTISELECT=true ``` diff --git a/src/components/ElementOverridesProvider/ElementOverridesProvider.test.tsx b/src/components/ElementOverridesProvider/ElementOverridesProvider.test.tsx index a584d8836..907634318 100644 --- a/src/components/ElementOverridesProvider/ElementOverridesProvider.test.tsx +++ b/src/components/ElementOverridesProvider/ElementOverridesProvider.test.tsx @@ -28,6 +28,7 @@ import { LayoutStateProvider } from '../Layout'; import { SlidesProvider } from '../Layout/SlidesProvider'; import { ElementOverridesProvider } from './ElementOverridesProvider'; import { useElementOverride } from './useElementOverride'; +import { useElementOverrides } from './useElementOverrides'; import { useSetElementOverride } from './useSetElementOverride'; let widgetApi: MockedWidgetApi; @@ -82,6 +83,25 @@ describe('useElementCoordsState', () => { }); }); + it('should return the original elements', () => { + const elementIds = ['element-1']; + const { result } = renderHook(() => useElementOverrides(elementIds), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + 'element-1': { + type: 'shape', + kind: 'ellipse', + fillColor: '#ffffff', + text: 'Hello World', + position: { x: 0, y: 1 }, + height: 100, + width: 50, + }, + }); + }); + it('should replace the element position', () => { const { result } = renderHook( () => { @@ -109,6 +129,36 @@ describe('useElementCoordsState', () => { }); }); + it('should replace the elements position', () => { + const elementIds = ['element-1']; + const { result } = renderHook( + () => { + const element = useElementOverrides(elementIds); + const setElementOverride = useSetElementOverride(); + return { element, setElementOverride }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setElementOverride('element-1', { + position: { x: 50, y: 51 }, + }); + }); + + expect(result.current.element).toEqual({ + 'element-1': { + type: 'shape', + kind: 'ellipse', + fillColor: '#ffffff', + text: 'Hello World', + position: { x: 50, y: 51 }, + height: 100, + width: 50, + }, + }); + }); + it('should replace the element height and width', () => { const { result } = renderHook( () => { diff --git a/src/components/ElementOverridesProvider/index.ts b/src/components/ElementOverridesProvider/index.ts index da7e7469e..0c4ed6f56 100644 --- a/src/components/ElementOverridesProvider/index.ts +++ b/src/components/ElementOverridesProvider/index.ts @@ -16,4 +16,5 @@ export { ElementOverridesProvider } from './ElementOverridesProvider'; export { useElementOverride } from './useElementOverride'; +export { useElementOverrides } from './useElementOverrides'; export { useSetElementOverride } from './useSetElementOverride'; diff --git a/src/components/ElementOverridesProvider/useElementOverrides.ts b/src/components/ElementOverridesProvider/useElementOverrides.ts new file mode 100644 index 000000000..9f15c832a --- /dev/null +++ b/src/components/ElementOverridesProvider/useElementOverrides.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useContext, useMemo } from 'react'; +import { useElements } from '../../state'; +import { Elements } from '../../state/types'; +import { + ElementOverride, + ElementOverrideGetterContext, +} from './ElementOverridesProvider'; + +export function useElementOverrides(elementIds: string[]): Elements { + const getElementOverride = useContext(ElementOverrideGetterContext); + + if (!getElementOverride) { + throw new Error( + 'useElementOverride can only be used inside of ', + ); + } + + const elements: Elements = useElements(elementIds); + + return useMemo( + () => + Object.fromEntries( + Object.entries(elements).map(([elementId, element]) => { + const override: ElementOverride | undefined = + getElementOverride(elementId); + return [ + elementId, + element.type === 'path' + ? { + ...element, + position: override?.position ?? element.position, + } + : { + ...element, + height: override?.height ?? element.height, + width: override?.width ?? element.width, + position: override?.position ?? element.position, + }, + ]; + }), + ), + [elements, getElementOverride], + ); +} diff --git a/src/components/Whiteboard/Element/ConnectedElement.tsx b/src/components/Whiteboard/Element/ConnectedElement.tsx index b23c0fa33..06aa7df19 100644 --- a/src/components/Whiteboard/Element/ConnectedElement.tsx +++ b/src/components/Whiteboard/Element/ConnectedElement.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useActiveElement } from '../../../state'; +import { useActiveElements } from '../../../state'; import { useElementOverride } from '../../ElementOverridesProvider'; import EllipseDisplay from '../../elements/ellipse/Display'; import LineDisplay from '../../elements/line/Display'; @@ -29,9 +29,12 @@ export const ConnectedElement = ({ id: string; readOnly?: boolean; }) => { - const { activeElementId } = useActiveElement(); + const { activeElementIds } = useActiveElements(); const element = useElementOverride(id); - const isActive = !readOnly && id ? activeElementId === id : false; + const isActive = + !readOnly && id + ? activeElementIds.length === 1 && activeElementIds[0] === id + : false; const otherProps = { // TODO: Align names active: isActive, diff --git a/src/components/Whiteboard/ElementBehaviors/Selection/ElementBarWrapper.tsx b/src/components/Whiteboard/ElementBehaviors/Selection/ElementBarWrapper.tsx index c60681c12..b5d119583 100644 --- a/src/components/Whiteboard/ElementBehaviors/Selection/ElementBarWrapper.tsx +++ b/src/components/Whiteboard/ElementBehaviors/Selection/ElementBarWrapper.tsx @@ -17,19 +17,17 @@ import { Box } from '@mui/material'; import { clamp } from 'lodash'; import { PropsWithChildren } from 'react'; -import { - calculateBoundingRectForPoints, - useSlideIsLocked, -} from '../../../../state'; -import { useElementOverride } from '../../../ElementOverridesProvider'; +import { useSlideIsLocked } from '../../../../state'; +import { calculateBoundingRectForElements } from '../../../../state/crdt/documents/elements'; +import { useElementOverrides } from '../../../ElementOverridesProvider'; import { useMeasure, useSvgCanvasContext } from '../../SvgCanvas'; export function ElementBarWrapper({ children, - elementId, -}: PropsWithChildren<{ elementId: string }>) { + elementIds, +}: PropsWithChildren<{ elementIds: string[] }>) { const isLocked = useSlideIsLocked(); - const element = useElementOverride(elementId); + const elements = Object.values(useElementOverrides(elementIds)); const [sizeRef, { width: elementBarWidth, height: elementBarHeight }] = useMeasure(); const { @@ -37,21 +35,20 @@ export function ElementBarWrapper({ width: canvasWidth, height: canvasHeight, } = useSvgCanvasContext(); - const width = - element?.type === 'path' - ? calculateBoundingRectForPoints(element.points).width - : element?.width ?? 0; - const height = - element?.type === 'path' - ? calculateBoundingRectForPoints(element.points).height - : element?.height ?? 0; + const { + offsetX: x, + offsetY: y, + width, + height, + } = calculateBoundingRectForElements(elements); + const offset = 10; function calculateTopPosition() { - if (!element) { + if (elements.length === 0) { return 0; } - const position = element.position.y * scale; + const position = y * scale; const positionAbove = position - elementBarHeight - offset; const positionBelow = position + height * scale + offset; const positionInElement = position + offset; @@ -66,19 +63,18 @@ export function ElementBarWrapper({ } function calculateLeftPosition() { - if (!element) { + if (elements.length === 0) { return 0; } - const position = - (element.position.x + width / 2) * scale - elementBarWidth / 2; + const position = (x + width / 2) * scale - elementBarWidth / 2; return clamp(position, 0, canvasWidth - elementBarWidth); } return ( <> - {element && !isLocked && ( + {elements.length !== 0 && !isLocked && ( diff --git a/src/components/Whiteboard/ElementBehaviors/Selection/SelectableElement.tsx b/src/components/Whiteboard/ElementBehaviors/Selection/SelectableElement.tsx index 1b7f8b6fc..a9791241c 100644 --- a/src/components/Whiteboard/ElementBehaviors/Selection/SelectableElement.tsx +++ b/src/components/Whiteboard/ElementBehaviors/Selection/SelectableElement.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { getEnvironment } from '@matrix-widget-toolkit/mui'; import { MouseEvent, PropsWithChildren } from 'react'; import { useWhiteboardSlideInstance } from '../../../../state'; import { useLayoutState } from '../../../Layout'; @@ -25,6 +26,9 @@ export function SelectableElement({ children, elementId, }: SelectableElementProps) { + const multiselect = + getEnvironment('REACT_APP_MULTISELECT', 'false') === 'true'; + const slideInstance = useWhiteboardSlideInstance(); const { activeTool } = useLayoutState(); const isInSelectionMode = activeTool === 'select'; @@ -32,7 +36,20 @@ export function SelectableElement({ function handleMouseDown(event: MouseEvent) { if (isInSelectionMode) { event.stopPropagation(); - slideInstance.setActiveElementId(elementId); + + if (!multiselect) { + slideInstance.setActiveElementId(elementId); + } else { + if (!event.shiftKey) { + if (!slideInstance.getActiveElementIds().includes(elementId)) { + slideInstance.setActiveElementId(elementId); + } + } else if (slideInstance.getActiveElementIds().includes(elementId)) { + slideInstance.unselectActiveElementId(elementId); + } else { + slideInstance.addActiveElementId(elementId); + } + } } } diff --git a/src/components/Whiteboard/WhiteboardHost.tsx b/src/components/Whiteboard/WhiteboardHost.tsx index b06e6437a..3740e6bf8 100644 --- a/src/components/Whiteboard/WhiteboardHost.tsx +++ b/src/components/Whiteboard/WhiteboardHost.tsx @@ -16,7 +16,7 @@ import { Box } from '@mui/material'; import { - useActiveElement, + useActiveElements, useIsWhiteboardLoading, usePresentationMode, useSlideElementIds, @@ -54,7 +54,7 @@ const WhiteboardHost = ({ }) => { const slideInstance = useWhiteboardSlideInstance(); const { isShowCollaboratorsCursors } = useLayoutState(); - const { activeElementId } = useActiveElement(); + const { activeElementIds } = useActiveElements(); return ( + activeElementIds.length > 0 && ( + ) @@ -93,10 +93,10 @@ const WhiteboardHost = ({ {!readOnly && } - {!readOnly && activeElementId && ( + {!readOnly && activeElementIds.length > 0 && ( <> - - + + )} diff --git a/src/state/crdt/documents/elements.test.ts b/src/state/crdt/documents/elements.test.ts index 3ebbd4708..7284b3ee3 100644 --- a/src/state/crdt/documents/elements.test.ts +++ b/src/state/crdt/documents/elements.test.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { isValidElement } from './elements'; +import { + mockEllipseElement, + mockLineElement, +} from '../../../lib/testUtils/documentTestUtils'; +import { calculateBoundingRectForElements, isValidElement } from './elements'; describe('isValidElement', () => { it.each(['line', 'polyline'])('should accept %j path event', (kind) => { @@ -147,3 +151,53 @@ describe('isValidElement', () => { expect(isValidElement(data)).toBe(false); }); }); + +describe('calculateBoundingRectForElements', () => { + it('should calculate the bounding rect for an array of elements with single shape element', () => { + const elements = [mockEllipseElement()]; + expect(calculateBoundingRectForElements(elements)).toEqual({ + offsetX: 0, + offsetY: 1, + width: 50, + height: 100, + }); + }); + + it('should calculate the bounding rect for an array of elements with single path element', () => { + const elements = [mockLineElement()]; + expect(calculateBoundingRectForElements(elements)).toEqual({ + offsetX: 0, + offsetY: 1, + width: 2, + height: 2, + }); + }); + + it('should calculate the bounding rect for an array of elements', () => { + const elements = [ + mockEllipseElement({ width: 2, height: 5 }), + mockLineElement({ + position: { x: 10, y: 0 }, + points: [ + { x: 0, y: 0 }, + { x: 2, y: 2 }, + ], + }), + ]; + expect(calculateBoundingRectForElements(elements)).toEqual({ + offsetX: 0, + offsetY: 0, + width: 12, + height: 6, + }); + }); + + it('should calculate the bounding rect for empty array', () => { + expect(calculateBoundingRectForElements([])).toEqual({ + offsetX: 0, + offsetY: 0, + width: 0, + height: 0, + }); + }); +}); diff --git a/src/state/crdt/documents/elements.ts b/src/state/crdt/documents/elements.ts index 65f601fdf..35dc90e14 100644 --- a/src/state/crdt/documents/elements.ts +++ b/src/state/crdt/documents/elements.ts @@ -16,7 +16,12 @@ import Joi from 'joi'; import loglevel from 'loglevel'; -import { Point, pointSchema } from './point'; +import { + BoundingRect, + calculateBoundingRectForPoints, + Point, + pointSchema, +} from './point'; export type ElementBase = { type: string; @@ -89,3 +94,40 @@ export function isValidElement(element: unknown): element is Element { return true; } + +export function calculateBoundingRectForElements( + elements: Element[], +): BoundingRect { + const element = elements.length === 1 ? elements[0] : undefined; + + const elementsBoundingRect = + elements.length > 1 + ? calculateBoundingRectForPoints( + elements.flatMap((e) => + e.type === 'path' + ? e.points.map((p) => ({ + x: e.position.x + p.x, + y: e.position.y + p.y, + })) + : [ + { x: e.position.x, y: e.position.y }, + { x: e.position.x + e.width, y: e.position.y + e.height }, + ], + ), + ) + : undefined; + + const x = element?.position.x ?? elementsBoundingRect?.offsetX ?? 0; + const y = element?.position.y ?? elementsBoundingRect?.offsetY ?? 0; + + const height = + element?.type === 'path' + ? calculateBoundingRectForPoints(element.points).height + : element?.height ?? elementsBoundingRect?.height ?? 0; + const width = + element?.type === 'path' + ? calculateBoundingRectForPoints(element.points).width + : element?.width ?? elementsBoundingRect?.width ?? 0; + + return { offsetX: x, offsetY: y, width, height }; +} diff --git a/src/state/crdt/documents/point.ts b/src/state/crdt/documents/point.ts index 158cb0512..c65c3ee7c 100644 --- a/src/state/crdt/documents/point.ts +++ b/src/state/crdt/documents/point.ts @@ -23,12 +23,14 @@ export const pointSchema = Joi.object({ y: Joi.number().strict().required(), }).unknown(); -export function calculateBoundingRectForPoints(points: Point[]): { +export type BoundingRect = { offsetX: number; offsetY: number; width: number; height: number; -} { +}; + +export function calculateBoundingRectForPoints(points: Point[]): BoundingRect { let minX = +(points[0]?.x ?? 0); let minY = +(points[0]?.y ?? 0); let maxX = +(points[0]?.x ?? 0); diff --git a/src/state/index.ts b/src/state/index.ts index f432073fb..39112029e 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -44,7 +44,9 @@ export { export { SlideProvider, useActiveElement, + useActiveElements, useElement, + useElements, useSlideElementIds, useSlideIsLocked, useWhiteboardSlideInstance, diff --git a/src/state/types.test.ts b/src/state/types.test.ts index 649a639c1..7db8af1eb 100644 --- a/src/state/types.test.ts +++ b/src/state/types.test.ts @@ -29,7 +29,7 @@ describe('isWhiteboardUndoManagerContext', () => { expect( isWhiteboardUndoManagerContext({ currentSlideId: 'slide-0', - currentElementId: undefined, + currentElementIds: undefined, }), ).toBe(true); }); @@ -38,7 +38,7 @@ describe('isWhiteboardUndoManagerContext', () => { expect( isWhiteboardUndoManagerContext({ currentSlideId: 'slide-0', - currentElementId: 'element-0', + currentElementIds: ['element-0'], }), ).toBe(true); }); @@ -47,7 +47,7 @@ describe('isWhiteboardUndoManagerContext', () => { expect( isWhiteboardUndoManagerContext({ currentSlideId: 'slide-0', - currentElementId: 'element-0', + currentElementIds: ['element-0'], additional: 'data', }), ).toBe(true); @@ -57,12 +57,13 @@ describe('isWhiteboardUndoManagerContext', () => { { currentSlideId: undefined }, { currentSlideId: null }, { currentSlideId: 111 }, - { currentElementId: null }, - { currentElementId: 111 }, + { currentElementIds: null }, + { currentElementIds: 111 }, + { currentElementIds: ['element-0', 111] }, ])('should reject context with patch %j', (patch: Object) => { const data = { currentSlideId: 'slide-0', - currentElementId: 'element-0', + currentElementIds: ['element-0'], ...patch, }; diff --git a/src/state/types.ts b/src/state/types.ts index f78732b2e..744d389fb 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -141,8 +141,12 @@ export type WhiteboardSlideInstance = { moveElementToTop(elementId: string): void; /** Returns the element or undefined if it not exists. */ getElement(elementId: string): Element | undefined; + /** Returns elements by ids. */ + getElements(elementIds: string[]): Elements; /** Observe the changes of an element. Emits undefined if the element is removed. */ observeElement(elementId: string): Observable; + /** Observe the changes of elements.*/ + observeElements(elementIds: string[]): Observable; /** Returns the list of all element ids in the correct order from back to front. */ getElementIds(): string[]; /** Observe the element ids to react to changes */ @@ -156,14 +160,32 @@ export type WhiteboardSlideInstance = { /** Observe the locked state of the slide */ observeIsLocked(): Observable; - /** Return the active element */ + /** + * Return the active element + * @deprecated to be replaced with getActiveElementIds + * */ getActiveElementId(): string | undefined; - /** Observe the active element */ + /** Return the active elements */ + getActiveElementIds(): string[]; + /** + * Observe the active element. First element is returned if multiple are active. + * @deprecated to be replaced with observeActiveElementIds + */ observeActiveElementId(): Observable; + /** Observe the active elements */ + observeActiveElementIds(): Observable; /** Select the active element */ setActiveElementId(elementId: string | undefined): void; + /** Select the active elements */ + setActiveElementIds(elementIds: string[] | undefined): void; + /** Adds the element to active */ + addActiveElementId(elementId: string): void; + /** Unselects the element if active */ + unselectActiveElementId(elementId: string): void; }; +export type Elements = Record; + /** * A document that is stored in a persistent storage and is kept up-to-date via * a real-time communication channel. @@ -199,7 +221,7 @@ export type PresentationManager = { /** The data that is stored in the UndoManager */ export type WhiteboardUndoManagerContext = { currentSlideId: string; - currentElementId?: string; + currentElementIds?: string[]; }; /** Validate if the value that was stored in the context is valid */ @@ -211,9 +233,10 @@ export function isWhiteboardUndoManagerContext( typeof context === 'object' && 'currentSlideId' in context && typeof context.currentSlideId === 'string' && - (!('currentElementId' in context) || - context.currentElementId === undefined || - typeof context.currentElementId === 'string') + (!('currentElementIds' in context) || + context.currentElementIds === undefined || + (Array.isArray(context.currentElementIds) && + context.currentElementIds.every((v) => typeof v === 'string'))) ) { return true; } diff --git a/src/state/useWhiteboardSlideInstance.test.tsx b/src/state/useWhiteboardSlideInstance.test.tsx index 568457d3c..1b0827de0 100644 --- a/src/state/useWhiteboardSlideInstance.test.tsx +++ b/src/state/useWhiteboardSlideInstance.test.tsx @@ -27,6 +27,7 @@ import { SlideProvider, useActiveElement, useElement, + useElements, useSlideElementIds, useSlideIsLocked, useWhiteboardSlideInstance, @@ -113,7 +114,7 @@ describe('useSlideElementIds', () => { }); }); -describe('useElement', () => { +describe('useElement(s)', () => { it('should return undefined if element does not exist', () => { const { result } = renderHook(() => useElement('element-1'), { wrapper: Wrapper, @@ -122,6 +123,15 @@ describe('useElement', () => { expect(result.current).toBeUndefined(); }); + it('should return undefined if elements does not exist', () => { + const elementIds = ['element-1', 'element-2']; + const { result } = renderHook(() => useElements(elementIds), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({}); + }); + it('should handle undefined element id', () => { const { result } = renderHook(() => useElement(undefined), { wrapper: Wrapper, @@ -143,6 +153,20 @@ describe('useElement', () => { expect(getElement).toBeCalledWith('element-0'); }); + it('should return the elements', () => { + const whiteboardSlideInstance = + activeWhiteboardInstance.getSlide('slide-0'); + const getElement = jest.spyOn(whiteboardSlideInstance, 'getElement'); + + const elementIds = ['element-0']; + const { result } = renderHook(() => useElements(elementIds), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ 'element-0': mockEllipseElement() }); + expect(getElement).toBeCalledWith('element-0'); + }); + it('should update if the element ids change', () => { const { result } = renderHook(() => useElement('element-0'), { wrapper: Wrapper, diff --git a/src/state/useWhiteboardSlideInstance.tsx b/src/state/useWhiteboardSlideInstance.tsx index 705516f5d..9196ee85d 100644 --- a/src/state/useWhiteboardSlideInstance.tsx +++ b/src/state/useWhiteboardSlideInstance.tsx @@ -18,7 +18,7 @@ import React, { PropsWithChildren, useContext, useMemo } from 'react'; import { EMPTY } from 'rxjs'; import { useLatestValue } from '../lib'; import { Element } from './crdt'; -import { WhiteboardSlideInstance } from './types'; +import { Elements, WhiteboardSlideInstance } from './types'; import { useActiveWhiteboardInstance } from './useActiveWhiteboardInstance'; const SlideContext = React.createContext(undefined); @@ -68,10 +68,32 @@ export function useElement(elementId: string | undefined): Element | undefined { ); } +export function useElements(elementIds: string[]): Elements { + const slideInstance = useWhiteboardSlideInstance(); + + const observable = useMemo( + () => slideInstance.observeElements(elementIds), + [elementIds, slideInstance], + ); + + return useLatestValue( + () => slideInstance.getElements(elementIds), + observable, + ); +} + type ActiveElement = { activeElementId: string | undefined; }; +type ActiveElements = { + activeElementIds: string[]; +}; + +/** + * Return the active element + * @deprecated to be replaced with useActiveElements + * */ export function useActiveElement(): ActiveElement { const slideInstance = useWhiteboardSlideInstance(); @@ -88,6 +110,22 @@ export function useActiveElement(): ActiveElement { return { activeElementId }; } +export function useActiveElements(): ActiveElements { + const slideInstance = useWhiteboardSlideInstance(); + + const observable = useMemo( + () => slideInstance.observeActiveElementIds(), + [slideInstance], + ); + + const activeElementIds = useLatestValue( + () => slideInstance.getActiveElementIds(), + observable, + ); + + return { activeElementIds }; +} + export function useSlideIsLocked(slideId?: string): boolean { const slideIdContext = useContext(SlideContext); diff --git a/src/state/whiteboardInstanceImpl.ts b/src/state/whiteboardInstanceImpl.ts index 2e04d289f..51532e0ab 100644 --- a/src/state/whiteboardInstanceImpl.ts +++ b/src/state/whiteboardInstanceImpl.ts @@ -229,10 +229,10 @@ export class WhiteboardInstanceImpl implements WhiteboardInstance { if ( state.currentSlideId === this.activeSlideId && - state.currentElementId + state.currentElementIds ) { - this.getSlide(state.currentSlideId).setActiveElementId( - state.currentElementId, + this.getSlide(state.currentSlideId).setActiveElementIds( + state.currentElementIds, ); } }); diff --git a/src/state/whiteboardSlideInstanceImpl.test.ts b/src/state/whiteboardSlideInstanceImpl.test.ts index a105eed90..ed6bf44e7 100644 --- a/src/state/whiteboardSlideInstanceImpl.test.ts +++ b/src/state/whiteboardSlideInstanceImpl.test.ts @@ -17,7 +17,10 @@ import { waitFor } from '@testing-library/react'; import { last } from 'lodash'; import { firstValueFrom, skip, Subject, take, toArray } from 'rxjs'; -import { mockLineElement } from '../lib/testUtils/documentTestUtils'; +import { + mockEllipseElement, + mockLineElement, +} from '../lib/testUtils/documentTestUtils'; import { CommunicationChannel, CommunicationChannelStatistics, @@ -432,6 +435,40 @@ describe('WhiteboardSlideInstanceImpl', () => { ]); }); + it('should observe elements', async () => { + const slideInstance = new WhiteboardSlideInstanceImpl( + communicationChannel, + slide0, + document, + '@user-id', + ); + + const element = mockLineElement(); + const element1 = mockEllipseElement(); + const elementId0 = slideInstance.addElement(element); + const elementId1 = slideInstance.addElement(element1); + + const elementUpdates = firstValueFrom( + slideInstance + .observeElements([elementId0, elementId1]) + .pipe(take(3), toArray()), + ); + + slideInstance.updateElement(elementId0, { + strokeColor: '#000000', + }); + slideInstance.removeElement(elementId0); + + expect(await elementUpdates).toEqual([ + { [elementId0]: element, [elementId1]: element1 }, + { + [elementId0]: { ...element, strokeColor: '#000000' }, + [elementId1]: element1, + }, + { [elementId1]: element1 }, + ]); + }); + it('should observe element ids', async () => { const slideInstance = new WhiteboardSlideInstanceImpl( communicationChannel, @@ -671,7 +708,7 @@ describe('WhiteboardSlideInstanceImpl', () => { ); const observedActiveElement = firstValueFrom( - slideInstance.observeActiveElementId().pipe(take(2), toArray()), + slideInstance.observeActiveElementId().pipe(take(4), toArray()), ); const element0 = slideInstance.addElement(mockLineElement()); @@ -679,7 +716,42 @@ describe('WhiteboardSlideInstanceImpl', () => { slideInstance.setActiveElementId(element0); expect(slideInstance.getActiveElementId()).toEqual(element0); - expect(await observedActiveElement).toEqual([undefined, element0]); + expect(await observedActiveElement).toEqual([ + undefined, + element0, + undefined, + element0, + ]); + }); + + it('should select multiple elements', async () => { + const slideInstance = new WhiteboardSlideInstanceImpl( + communicationChannel, + slide0, + document, + '@user-id', + ); + + const element0 = slideInstance.addElement(mockLineElement()); + const element1 = slideInstance.addElement(mockLineElement()); + slideInstance.setActiveElementId('not-exists'); + + const observedActiveElements = firstValueFrom( + slideInstance.observeActiveElementIds().pipe(take(3), toArray()), + ); + + expect(slideInstance.getActiveElementIds()).toEqual([]); + + slideInstance.addActiveElementId(element0); + slideInstance.addActiveElementId(element1); + slideInstance.addActiveElementId(element1); + + expect(slideInstance.getActiveElementIds()).toEqual([element0, element1]); + expect(await observedActiveElements).toEqual([ + [], + [element0], + [element0, element1], + ]); }); it('should unset a selected element to a specific element', async () => { @@ -732,6 +804,56 @@ describe('WhiteboardSlideInstanceImpl', () => { expect(slideInstance.getActiveElementId()).toEqual(element0); }); + it('should unselect specific elements when multiple elements are selected', async () => { + const slideInstance = new WhiteboardSlideInstanceImpl( + communicationChannel, + slide0, + document, + '@user-id', + ); + + const element0 = slideInstance.addElement(mockLineElement()); + const element1 = slideInstance.addElement(mockLineElement()); + slideInstance.setActiveElementId('not-exists'); + + const observedActiveElement = firstValueFrom( + slideInstance.observeActiveElementIds().pipe(take(5), toArray()), + ); + + slideInstance.addActiveElementId(element0); + slideInstance.addActiveElementId(element1); + slideInstance.unselectActiveElementId(element0); + slideInstance.unselectActiveElementId(element1); + + expect(slideInstance.getActiveElementIds()).toEqual([]); + expect(await observedActiveElement).toEqual([ + [], + [element0], + [element0, element1], + [element1], + [], + ]); + }); + + it('should check if specific element is active when multiple elements are selected', async () => { + const slideInstance = new WhiteboardSlideInstanceImpl( + communicationChannel, + slide0, + document, + '@user-id', + ); + + const element0 = slideInstance.addElement(mockLineElement()); + const element1 = slideInstance.addElement(mockLineElement()); + slideInstance.setActiveElementId(undefined); + slideInstance.addActiveElementId(element0); + + expect(slideInstance.getActiveElementIds()).toEqual([element0]); + + slideInstance.addActiveElementId(element1); + expect(slideInstance.getActiveElementIds()).toEqual([element0, element1]); + }); + it('should deselect the active element when removed', async () => { const slideInstance = new WhiteboardSlideInstanceImpl( communicationChannel, diff --git a/src/state/whiteboardSlideInstanceImpl.ts b/src/state/whiteboardSlideInstanceImpl.ts index f008562e0..d75208cb9 100644 --- a/src/state/whiteboardSlideInstanceImpl.ts +++ b/src/state/whiteboardSlideInstanceImpl.ts @@ -14,31 +14,34 @@ * limitations under the License. */ -import { isEqual } from 'lodash'; +import { first, isEqual } from 'lodash'; import { + Observable, + Subject, combineLatest, concat, defer, distinctUntilChanged, filter, map, - Observable, of, scan, - Subject, takeUntil, throttleTime, timer, } from 'rxjs'; import { - CommunicationChannel, CURSOR_UPDATE_MESSAGE, + CommunicationChannel, CursorUpdate, isValidCursorUpdateMessage, } from './communication'; import { Document, Element, + Point, + UpdateElementPatch, + WhiteboardDocument, generateAddElement, generateLockSlide, generateMoveDown, @@ -51,17 +54,18 @@ import { getElement, getNormalizedElementIds, getSlideLock, - Point, - UpdateElementPatch, - WhiteboardDocument, } from './crdt'; -import { WhiteboardSlideInstance, WhiteboardUndoManagerContext } from './types'; +import { + Elements, + WhiteboardSlideInstance, + WhiteboardUndoManagerContext, +} from './types'; export class WhiteboardSlideInstanceImpl implements WhiteboardSlideInstance { private readonly destroySubject = new Subject(); - private readonly activeElementIdSubject = new Subject(); - private activeElementId: string | undefined = undefined; + private readonly activeElementIdsSubject = new Subject(); + private activeElementIds: string[] = []; private readonly dataObservable = concat( defer(() => of(this.document.getData())), @@ -112,7 +116,7 @@ export class WhiteboardSlideInstanceImpl implements WhiteboardSlideInstance { this.observeElementIds() .pipe(takeUntil(this.destroySubject)) .subscribe(() => { - this.activeElementIdSubject.next(this.getActiveElementId()); + this.activeElementIdsSubject.next(this.getActiveElementIds()); }); this.cursorPositionSubject @@ -198,6 +202,17 @@ export class WhiteboardSlideInstanceImpl implements WhiteboardSlideInstance { )?.toJSON(); } + getElements(elementIds: string[]): Elements { + const elements: Elements = {}; + for (const elementId of elementIds) { + const element = this.getElement(elementId); + if (element) { + elements[elementId] = element; + } + } + return elements; + } + observeElement(elementId: string): Observable { return this.dataObservable.pipe( map((doc) => getElement(doc, this.slideId, elementId)?.toJSON()), @@ -205,6 +220,22 @@ export class WhiteboardSlideInstanceImpl implements WhiteboardSlideInstance { ); } + observeElements(elementIds: string[]): Observable { + return this.dataObservable.pipe( + map((doc) => { + const elements: Elements = {}; + for (const elementId of elementIds) { + const element = getElement(doc, this.slideId, elementId)?.toJSON(); + if (element) { + elements[elementId] = element; + } + } + return elements; + }), + distinctUntilChanged(isEqual), + ); + } + getElementIds(): string[] { return getNormalizedElementIds(this.document.getData(), this.slideId); } @@ -222,26 +253,80 @@ export class WhiteboardSlideInstanceImpl implements WhiteboardSlideInstance { } getActiveElementId(): string | undefined { - return this.activeElementId && - this.getElementIds().includes(this.activeElementId) - ? this.activeElementId + const activeElementId = first(this.activeElementIds); + return activeElementId && this.getElementIds().includes(activeElementId) + ? activeElementId : undefined; } + getActiveElementIds(): string[] { + return this.activeElementIds.filter((activeElementId) => + this.getElementIds().includes(activeElementId), + ); + } + observeActiveElementId(): Observable { + return this.observeActiveElementIds().pipe( + map((elements) => first(elements)), + ); + } + + observeActiveElementIds(): Observable { return concat( - defer(() => of(this.getActiveElementId())), - this.activeElementIdSubject, - ).pipe(distinctUntilChanged()); + defer(() => of(this.getActiveElementIds())), + this.activeElementIdsSubject, + ).pipe(distinctUntilChanged(isEqual)); } setActiveElementId(elementId: string | undefined): void { - this.activeElementId = elementId; - this.activeElementIdSubject.next(this.getActiveElementId()); + this.activeElementIds = elementId ? [elementId] : []; + this.activeElementIdsSubject.next(this.getActiveElementIds()); + + this.updateDocumentUndoManagerCurrentElement( + elementId ? [elementId] : undefined, + ); + } + + setActiveElementIds(elementIds: string[] | undefined): void { + this.activeElementIds = elementIds ?? []; + this.activeElementIdsSubject.next(this.getActiveElementIds()); + + this.updateDocumentUndoManagerCurrentElement(elementIds); + } + + addActiveElementId(elementId: string): void { + if ( + this.getElementIds().includes(elementId) && + !this.activeElementIds.includes(elementId) + ) { + this.activeElementIds = [ + ...this.activeElementIds.filter((activeElementId) => + this.getElementIds().includes(activeElementId), + ), + elementId, + ]; + this.activeElementIdsSubject.next(this.activeElementIds); + + this.updateDocumentUndoManagerCurrentElement(this.activeElementIds); + } + } + + unselectActiveElementId(elementId: string): void { + const activeElementIds = this.activeElementIds.filter( + (activeElementId) => activeElementId !== elementId, + ); + this.activeElementIds = activeElementIds; + this.activeElementIdsSubject.next(activeElementIds); + + this.updateDocumentUndoManagerCurrentElement(this.activeElementIds); + } + private updateDocumentUndoManagerCurrentElement( + elementIds: string[] | undefined, + ): void { this.document.getUndoManager().setContext({ currentSlideId: this.slideId, - currentElementId: this.activeElementId, + currentElementIds: elementIds, }); } @@ -258,7 +343,7 @@ export class WhiteboardSlideInstanceImpl implements WhiteboardSlideInstance { destroy() { this.destroySubject.next(); - this.activeElementIdSubject.complete(); + this.activeElementIdsSubject.complete(); this.cursorPositionSubject.complete(); }