From d8383566dff89beee0935055daa1cb293699707d Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Thu, 13 Nov 2025 01:26:37 +0300 Subject: [PATCH] Allow to configure `DropOnCanvas` to allow only some drop events and provide items to place on the canvas --- CHANGELOG.md | 1 + src/diagram/canvasApi.ts | 28 +++++ src/diagram/geometry.ts | 5 + src/diagram/paperArea.tsx | 34 ++++-- src/diagram/sharedCanvasState.ts | 10 ++ src/editor/dataDiagramModel.ts | 10 ++ src/widgets/connectionsMenu.tsx | 6 +- src/widgets/dropOnCanvas.tsx | 158 +++++++++++++++++++++----- src/widgets/utility/searchResults.tsx | 14 +-- src/workspace.ts | 10 +- 10 files changed, 223 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00836411..bd278a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p * Resizable elements display "box with handles" in the `Halo` to change the size; * Changed element sizes are captured and restored by `RestoreGeometry` command. - Allow to customize link template separately for each link instead of only based on its link type IRI in `linkTemplateResolver` for `Canvas`. +- Allow to configure `DropOnCanvas` to allow only some drop events and provide items to place on the canvas. - Support keyboard hotkeys for `LinkAction` components to act on a currently selected link. #### ⏱ Performance diff --git a/src/diagram/canvasApi.ts b/src/diagram/canvasApi.ts index 736bf0fd..3cee136a 100644 --- a/src/diagram/canvasApi.ts +++ b/src/diagram/canvasApi.ts @@ -175,6 +175,11 @@ export interface CanvasEvents { * event in the canvas. */ scroll: CanvasScrollEvent; + /** + * Triggered on [dragover](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dragover_event) + * event from a drag and drop operation on the canvas. + */ + dragover: CanvasDragoverEvent; /** * Triggered on [drop](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event) * event from a drag and drop operation on the canvas. @@ -269,6 +274,29 @@ export interface CanvasScrollEvent { readonly sourceEvent: Event; } +/** + * Event data for canvas dragover event from a drag and drop operation. + */ +export interface CanvasDragoverEvent { + /** + * Event source (canvas). + */ + readonly source: CanvasApi; + /** + * Original (raw) event data. + */ + readonly sourceEvent: DragEvent; + /** + * Position of the dragged item in paper coordinates. + */ + readonly position: Vector; + /** + * If called from any handler, allows the drop operation to proceed; + * otherwise the drop event would be cancelled. + */ + readonly allowDrop: () => void; +} + /** * Event data for canvas drop event from a drag and drop operation. */ diff --git a/src/diagram/geometry.ts b/src/diagram/geometry.ts index 9fa6edee..4bb5b047 100644 --- a/src/diagram/geometry.ts +++ b/src/diagram/geometry.ts @@ -525,12 +525,17 @@ export function getContentFittingBox( /** * Computes average center position of element bounding boxes. * + * When `elements` is empty, returns `{x: 0, y: 0}`. + * * @category Geometry */ export function calculateAveragePosition( elements: ReadonlyArray, sizeProvider: SizeProvider ): Vector { + if (elements.length === 0) { + return {x: 0, y: 0}; + } let xSum = 0; let ySum = 0; for (const element of elements) { diff --git a/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index e58139d5..7defd3c7 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -8,7 +8,7 @@ import { Debouncer, animateInterval, easeInOutBezier } from '../coreUtils/schedu import { CanvasContext, CanvasApi, CanvasEvents, CanvasMetrics, CanvasAreaMetrics, - CanvasDropEvent, CenterToOptions, ScaleOptions, ViewportOptions, + CanvasDragoverEvent, CanvasDropEvent, CenterToOptions, ScaleOptions, ViewportOptions, CanvasPointerMode, ZoomOptions, ExportSvgOptions, ExportRasterOptions, } from './canvasApi'; import { RestoreGeometry } from './commands'; @@ -812,13 +812,33 @@ export class PaperArea extends React.Component implements } private onDragOver = (e: DragEvent) => { - // Necessary. Allows us to drop. - if (e.preventDefault) { e.preventDefault(); } - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'move'; + const {renderingState} = this.props; + if (renderingState.shared.hasHandlerForNextDropOnPaper()) { + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + // Allow to drop + e?.preventDefault(); + } else { + const {x, y} = clientCoordsFor(this.area, e); + const position = this.metrics.clientToPaperCoords(x, y); + let allowDrop = false; + const event: CanvasDragoverEvent = { + source: this, + sourceEvent: e, + position, + allowDrop: () => { + allowDrop = true; + }, + }; + this.source.trigger('dragover', event); + if (allowDrop) { + // Allow to drop + e?.preventDefault(); + } else if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'none'; + } } - const {x, y} = clientCoordsFor(this.area, e); - return false; }; private onDragDrop = (e: DragEvent) => { diff --git a/src/diagram/sharedCanvasState.ts b/src/diagram/sharedCanvasState.ts index 7689a35e..cb7bce8d 100644 --- a/src/diagram/sharedCanvasState.ts +++ b/src/diagram/sharedCanvasState.ts @@ -144,6 +144,16 @@ export class SharedCanvasState { this.dropOnPaperHandler = handler; } + /** + * Returns `true` if there is a previously set drop handler on a canvas, + * otherwise `false`. + * + * **Experimental**: this feature will likely change in the future. + */ + hasHandlerForNextDropOnPaper(): boolean { + return Boolean(this.dropOnPaperHandler); + } + /** * Tries to run previously set drop handler on a canvas, * then removes the handler if it was set. diff --git a/src/editor/dataDiagramModel.ts b/src/editor/dataDiagramModel.ts index b0a015a6..e038dac6 100644 --- a/src/editor/dataDiagramModel.ts +++ b/src/editor/dataDiagramModel.ts @@ -1105,3 +1105,13 @@ export function restoreLinksBetweenElements( () => void model.requestLinks(options) ); } + +export function getAllPresentEntities(graph: DataGraphStructure): Set { + const presentOnDiagram = new Set(); + for (const element of graph.elements) { + for (const entity of iterateEntitiesOf(element)) { + presentOnDiagram.add(entity.id); + } + } + return presentOnDiagram; +} diff --git a/src/widgets/connectionsMenu.tsx b/src/widgets/connectionsMenu.tsx index bf237ca0..e371d7e0 100644 --- a/src/widgets/connectionsMenu.tsx +++ b/src/widgets/connectionsMenu.tsx @@ -16,7 +16,9 @@ import { DiagramModel } from '../diagram/model'; import { HtmlSpinner } from '../diagram/spinner'; import { BuiltinDialogType } from '../editor/builtinDialogType'; -import { requestElementData, restoreLinksBetweenElements } from '../editor/dataDiagramModel'; +import { + requestElementData, restoreLinksBetweenElements, getAllPresentEntities, +} from '../editor/dataDiagramModel'; import { EntityElement, EntityGroup, iterateEntitiesOf } from '../editor/dataElements'; import { WithFetchStatus } from '../editor/withFetchStatus'; @@ -27,7 +29,7 @@ import { type WorkspaceContext, WorkspaceEventKey, useWorkspace } from '../works import { ConnectionsMenuTopic, InstancesSearchTopic } from '../workspace/commandBusTopic'; import { highlightSubstring } from './utility/listElementView'; -import { SearchResults, getAllPresentEntities } from './utility/searchResults'; +import { SearchResults } from './utility/searchResults'; /** * Props for {@link ConnectionsMenu} component. diff --git a/src/widgets/dropOnCanvas.tsx b/src/widgets/dropOnCanvas.tsx index bec6c928..f8a0215e 100644 --- a/src/widgets/dropOnCanvas.tsx +++ b/src/widgets/dropOnCanvas.tsx @@ -5,11 +5,16 @@ import { TranslatedText } from '../coreUtils/i18n'; import { ElementIri } from '../data/model'; -import { CanvasApi, useCanvas } from '../diagram/canvasApi'; +import { + type CanvasApi, type CanvasDragoverEvent, type CanvasDropEvent, useCanvas, +} from '../diagram/canvasApi'; +import { Element } from '../diagram/elements'; import { Vector, boundsOf } from '../diagram/geometry'; -import { DataDiagramModel, requestElementData, restoreLinksBetweenElements } from '../editor/dataDiagramModel'; -import { EntityElement } from '../editor/dataElements'; +import { + requestElementData, restoreLinksBetweenElements, getAllPresentEntities, +} from '../editor/dataDiagramModel'; +import { EntityElement, iterateEntitiesOf } from '../editor/dataElements'; import { WorkspaceEventKey, useWorkspace } from '../workspace/workspaceContext'; @@ -18,31 +23,102 @@ import { WorkspaceEventKey, useWorkspace } from '../workspace/workspaceContext'; * * @see {@link DropOnCanvas} */ -export interface DropOnCanvasProps {} +export interface DropOnCanvasProps { + /** + * Handler to check whether the drop is allowed. + * + * If not specified, all drag events are allowed. + */ + allowDrop?: (e: CanvasDragoverEvent) => boolean; + /** + * Handler to make diagram elements from drop event to add on the canvas. + * + * **Default**: {@link defaultGetDroppedOnCanvasItems} + */ + getDroppedItems?: (e: CanvasDropEvent) => ReadonlyArray; +} + +/** + * Dropped on the canvas item. + * + * @see {@link DropOnCanvasProps.getDroppedItems} + */ +export type DropOnCanvasItem = DropItemElement; /** - * Canvas widget component to allow creating entity elements on the diagram - * by dragging then dropping a URL (IRI) to the canvas. + * Dropped on the canvas element to place there. + * + * @see {@link DropOnCanvasItem} + */ +export interface DropItemElement { + readonly type: 'element'; + readonly element: Element; +} + +/** + * Canvas widget component to allow creating elements on the diagram + * by dragging then dropping an IRI (URI) to the canvas. * * @category Components */ export function DropOnCanvas(props: DropOnCanvasProps) { + const {allowDrop, getDroppedItems = defaultGetDroppedOnCanvasItems} = props; const {canvas} = useCanvas(); const {model, triggerWorkspaceEvent} = useWorkspace(); React.useEffect(() => { const listener = new EventObserver(); + listener.listen(canvas.events, 'dragover', e => { + if (!allowDrop || allowDrop(e)) { + if (e.sourceEvent.dataTransfer) { + e.sourceEvent.dataTransfer.dropEffect = 'move'; + } + e.allowDrop(); + } + }); listener.listen(canvas.events, 'drop', e => { e.sourceEvent.preventDefault(); - const iris = tryParseDefaultDragAndDropData(e.sourceEvent); - if (iris.length > 0) { + const items = getDroppedItems(e); + if (items.length > 0) { const batch = model.history.startBatch(TranslatedText.text('drop_on_canvas.drop.command')); - const placedElements = placeElements(iris, e.position, canvas, model); - const irisToLoad = placedElements.map(elem => elem.iri); + + const presentOnDiagram = getAllPresentEntities(model); + + const addedIris = new Set(); + const irisToLoad: ElementIri[] = []; + const placedElements: Element[] = []; + + for (const item of items) { + if (item.type === 'element') { + let addElement = false; + + for (const entity of iterateEntitiesOf(item.element)) { + if (!(presentOnDiagram.has(entity.id) || addedIris.has(entity.id))) { + addElement = true; + addedIris.add(entity.id); + if (EntityElement.isPlaceholderData(entity)) { + irisToLoad.push(entity.id); + } + } + } + + if (addElement) { + placedElements.push(item.element); + } + } + } + + for (const element of placedElements) { + if (!model.getElement(element.id)) { + model.addElement(element); + } + } + + placeElements(placedElements, e.position, canvas); batch.history.execute(requestElementData(model, irisToLoad)); batch.history.execute(restoreLinksBetweenElements(model, { - addedElements: iris, + addedElements: Array.from(addedIris), })); batch.store(); @@ -56,43 +132,67 @@ export function DropOnCanvas(props: DropOnCanvasProps) { } }); return () => listener.stopListening(); - }, []); + }, [allowDrop, getDroppedItems]); return null; } -function tryParseDefaultDragAndDropData(e: DragEvent): ElementIri[] { - const tryGetIri = (type: string, decode: boolean = false) => { +/** + * Default handler to create {@link EntityElement entity elements} from a drop event. + * + * The handler tries to extract IRIs from drop event data in the following order: + * 1. Parse data with `application/x-reactodia-elements` format as JSON array of strings; + * 2. Decode a URI from a single string with `text/uri-list` format; + * 3. Use as-is string with `text` format. + * + * @see {@link DropOnCanvas} + * @see {@link DropOnCanvasProps.getDroppedItems} + */ +export function defaultGetDroppedOnCanvasItems(e: CanvasDropEvent): DropOnCanvasItem[] { + const tryGetIri = (type: string, decode: boolean = false): DropOnCanvasItem[] | undefined => { try { - const iriString = e.dataTransfer!.getData(type); - if (!iriString) { return undefined; } - let iris: ElementIri[]; + const iriString = e.sourceEvent.dataTransfer?.getData(type); + if (!iriString) { + return undefined; + } + + let iris: ElementIri[] = []; try { - iris = JSON.parse(iriString) as ElementIri[]; + const parsed: unknown = JSON.parse(iriString); + if (Array.isArray(parsed)) { + iris = parsed.filter((iri): iri is ElementIri => typeof iri === 'string'); + } } catch (e) { iris = [(decode ? decodeURI(iriString) : iriString)]; } - return iris.length === 0 ? undefined : iris; + + if (iris.length === 0) { + return undefined; + } + return iris.map((iri): DropOnCanvasItem => ({ + type: 'element', + element: new EntityElement({ + data: EntityElement.placeholderData(iri), + }), + })); } catch (e) { return undefined; } }; return tryGetIri('application/x-reactodia-elements') - || tryGetIri('text/uri-list', true) - || tryGetIri('text') // IE11, Edge - || []; + ?? tryGetIri('text/uri-list', true) + ?? tryGetIri('text') // IE11, Edge + ?? []; } function placeElements( - dragged: ReadonlyArray, + elements: ReadonlyArray, position: Vector, - canvas: CanvasApi, - model: DataDiagramModel -): EntityElement[] { - const elements = dragged.map(item => model.createElement(item)); + canvas: CanvasApi +): void { for (const element of elements) { - // initially anchor element at top left corner to preserve canvas scroll state, + // Initially anchor element at top left corner to preserve canvas scroll state, // measure it and only then move to center-anchored position element.setPosition(position); } @@ -114,6 +214,4 @@ function placeElements( element.setPosition({x, y}); y += height + 20; } - - return elements; } diff --git a/src/widgets/utility/searchResults.tsx b/src/widgets/utility/searchResults.tsx index dac1228a..fc6bc608 100644 --- a/src/widgets/utility/searchResults.tsx +++ b/src/widgets/utility/searchResults.tsx @@ -5,8 +5,8 @@ import { Debouncer } from '../../coreUtils/scheduler'; import { ElementModel, ElementIri } from '../../data/model'; -import type { DataGraphStructure } from '../../editor/dataDiagramModel'; -import { EntityElement, iterateEntitiesOf } from '../../editor/dataElements'; +import { getAllPresentEntities } from '../../editor/dataDiagramModel'; +import { EntityElement } from '../../editor/dataElements'; import { WorkspaceContext, useWorkspace } from '../../workspace/workspaceContext'; @@ -306,13 +306,3 @@ class SearchResultsInner extends React.Component { item.focus(); } } - -export function getAllPresentEntities(graph: DataGraphStructure): Set { - const presentOnDiagram = new Set(); - for (const element of graph.elements) { - for (const entity of iterateEntitiesOf(element)) { - presentOnDiagram.add(entity.id); - } - } - return presentOnDiagram; -} diff --git a/src/workspace.ts b/src/workspace.ts index e0ff19ec..cce1d088 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -117,7 +117,10 @@ export { TemporaryState, } from './editor/authoringState'; export { BuiltinDialogType } from './editor/builtinDialogType'; -export * from './editor/dataDiagramModel'; +export { + type DataDiagramModel, type DataDiagramModelEvents, type DataGraphStructure, + requestElementData, restoreLinksBetweenElements, type RequestLinksOptions, +} from './editor/dataDiagramModel'; export { EntityElement, type EntityElementEvents, type EntityElementProps, EntityGroup, type EntityGroupEvents, type EntityGroupProps, type EntityGroupItem, @@ -208,7 +211,10 @@ export { type PropertySuggestionHandler, type PropertySuggestionParams, type PropertyScore, } from './widgets/connectionsMenu'; export type { DialogStyleProps } from './widgets/dialog'; -export { DropOnCanvas, type DropOnCanvasProps } from './widgets/dropOnCanvas'; +export { + DropOnCanvas, type DropOnCanvasProps, type DropOnCanvasItem, type DropItemElement, + defaultGetDroppedOnCanvasItems, +} from './widgets/dropOnCanvas'; export { Halo, type HaloProps } from './widgets/halo'; export { HaloLink, type HaloLinkProps } from './widgets/haloLink'; export {