Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/diagram/canvasApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/diagram/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element>,
sizeProvider: SizeProvider
): Vector {
if (elements.length === 0) {
return {x: 0, y: 0};
}
let xSum = 0;
let ySum = 0;
for (const element of elements) {
Expand Down
34 changes: 27 additions & 7 deletions src/diagram/paperArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -812,13 +812,33 @@ export class PaperArea extends React.Component<PaperAreaProps, State> 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) => {
Expand Down
10 changes: 10 additions & 0 deletions src/diagram/sharedCanvasState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/editor/dataDiagramModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,3 +1105,13 @@ export function restoreLinksBetweenElements(
() => void model.requestLinks(options)
);
}

export function getAllPresentEntities(graph: DataGraphStructure): Set<ElementIri> {
const presentOnDiagram = new Set<ElementIri>();
for (const element of graph.elements) {
for (const entity of iterateEntitiesOf(element)) {
presentOnDiagram.add(entity.id);
}
}
return presentOnDiagram;
}
6 changes: 4 additions & 2 deletions src/widgets/connectionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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.
Expand Down
158 changes: 128 additions & 30 deletions src/widgets/dropOnCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<DropOnCanvasItem>;
}

/**
* 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<ElementIri>();
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();

Expand All @@ -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<ElementIri>,
elements: ReadonlyArray<Element>,
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);
}
Expand All @@ -114,6 +214,4 @@ function placeElements(
element.setPosition({x, y});
y += height + 20;
}

return elements;
}
Loading