@plait/angular-board
Version:
616 lines (607 loc) • 27.4 kB
JavaScript
import * as i0 from '@angular/core';
import { Directive, Input, EventEmitter, viewChild, ElementRef, ContentChildren, ViewChild, HostBinding, Output, ChangeDetectionStrategy, Component } from '@angular/core';
import rough from 'roughjs/bin/rough';
import { Subject, fromEvent } from 'rxjs';
import { takeUntil, filter, tap } from 'rxjs/operators';
import { HOST_CLASS_NAME, IS_SAFARI, IS_CHROME, IS_FIREFOX, PlaitBoard, BOARD_TO_MOVING_POINT, BOARD_TO_ROUGH_SVG, BOARD_TO_HOST, IS_BOARD_ALIVE, BOARD_TO_ELEMENT_HOST, BOARD_TO_ON_CHANGE, isFromScrolling, setIsFromScrolling, initializeViewBox, updateViewBox, updateViewportOffset, getSelectedElements, PlaitElement, BOARD_TO_AFTER_CHANGE, PlaitBoardContext, BOARD_TO_CONTEXT, FLUSHING, initializeViewportContainer, initializeViewportOffset, withRelatedFragment, withHotkey, withHandPointer, withHistory, withSelection, withMoving, withBoard, withI18n, withOptions, createBoard, KEY_TO_ELEMENT_MAP, BOARD_TO_MOVING_POINT_IN_BOARD, hasInputOrTextareaTarget, setFragment, WritableClipboardOperationType, toViewBoxPoint, toHostPoint, getClipboardData, deleteFragment, ListRender, isFromViewportChange, setIsFromViewportChange, updateViewportByScrolling, ZOOM_STEP, BoardTransforms } from '@plait/core';
import { PlaitTextComponent } from '@plait/angular-text';
import { AngularEditor } from 'slate-angular';
import { withImage, withText } from '@plait/common';
const BOARD_TO_COMPONENT = new WeakMap();
class PlaitIslandBaseComponent {
constructor(cdr) {
this.cdr = cdr;
}
initialize(board) {
this.board = board;
this.markForCheck();
}
markForCheck() {
this.cdr.markForCheck();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: PlaitIslandBaseComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.14", type: PlaitIslandBaseComponent, isStandalone: false, host: { classAttribute: "plait-island-container" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: PlaitIslandBaseComponent, decorators: [{
type: Directive,
args: [{
host: {
class: 'plait-island-container'
},
standalone: false
}]
}], ctorParameters: () => [{ type: i0.ChangeDetectorRef }] });
class PlaitIslandPopoverBaseComponent {
constructor(cdr) {
this.cdr = cdr;
}
initialize(board) {
this.board = board;
const boardComponent = BOARD_TO_COMPONENT.get(board);
this.subscription = boardComponent.onChange.subscribe(() => {
if (hasOnBoardChange(this)) {
this.onBoardChange();
}
this.cdr.markForCheck();
});
}
ngOnInit() {
if (!this.board) {
throw new Error('can not find board instance');
}
this.initialize(this.board);
this.islandOnInit();
}
ngOnDestroy() {
this.subscription?.unsubscribe();
this.islandOnDestroy();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: PlaitIslandPopoverBaseComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.14", type: PlaitIslandPopoverBaseComponent, isStandalone: false, inputs: { board: "board" }, host: { classAttribute: "plait-island-popover-container" }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: PlaitIslandPopoverBaseComponent, decorators: [{
type: Directive,
args: [{
host: {
class: 'plait-island-popover-container'
},
standalone: false
}]
}], ctorParameters: () => [{ type: i0.ChangeDetectorRef }], propDecorators: { board: [{
type: Input
}] } });
const hasOnBoardChange = (value) => {
if (value.onBoardChange) {
return true;
}
else {
return false;
}
};
const withAngular = (board) => {
const newBoard = board;
newBoard.renderComponent = (type, container, props) => {
const boardComponent = BOARD_TO_COMPONENT.get(board);
const componentRef = boardComponent.viewContainerRef.createComponent(type);
for (const key in props) {
const value = props[key];
componentRef.instance[key] = value;
}
container.appendChild(componentRef.instance.nativeElement());
componentRef.changeDetectorRef.detectChanges();
const ref = {
destroy: () => {
componentRef.destroy();
},
update: (props) => {
for (const key in props) {
const value = props[key];
componentRef.instance[key] = value;
}
// solve image lose on move node
if (container.children.length === 0) {
container.append(componentRef.instance.nativeElement());
}
componentRef.changeDetectorRef.detectChanges();
}
};
return { ref, componentRef };
};
newBoard.renderText = (container, props) => {
const { ref, componentRef } = newBoard.renderComponent(PlaitTextComponent, container, props);
const { update } = ref;
ref.update = props => {
const beforeReadonly = componentRef.instance.readonly;
update(props);
if (beforeReadonly === true && props.readonly === false) {
AngularEditor.focus(componentRef.instance.editor);
}
else if (beforeReadonly === false && props.readonly === true) {
AngularEditor.blur(componentRef.instance.editor);
}
};
return ref;
};
return newBoard;
};
const ElementLowerHostClass = 'element-lower-host';
const ElementHostClass = 'element-host';
const ElementUpperHostClass = 'element-upper-host';
const ElementTopHostClass = 'element-top-host';
class PlaitBoardComponent {
get host() {
return this.svg.nativeElement;
}
get hostClass() {
return `${HOST_CLASS_NAME} theme-${this.board.theme.themeColorMode} ${this.getBrowserClassName()} pointer-${this.board.pointer}`;
}
getBrowserClassName() {
if (IS_SAFARI) {
return 'safari';
}
if (IS_CHROME) {
return 'chrome';
}
if (IS_FIREFOX) {
return 'firefox';
}
return '';
}
get readonly() {
return this.board.options.readonly;
}
get isFocused() {
return PlaitBoard.isFocus(this.board);
}
get disabledScrollOnNonFocus() {
return this.board.options.disabledScrollOnNonFocus && !PlaitBoard.isFocus(this.board);
}
get nativeElement() {
return this.elementRef.nativeElement;
}
constructor(cdr, injector, viewContainerRef, elementRef, ngZone) {
this.cdr = cdr;
this.injector = injector;
this.viewContainerRef = viewContainerRef;
this.elementRef = elementRef;
this.ngZone = ngZone;
this.hasInitialized = false;
this.destroy$ = new Subject();
this.plaitValue = [];
this.plaitPlugins = [];
this.onChange = new EventEmitter();
this.plaitBoardInitialized = new EventEmitter();
this.activeHostG = viewChild('activeHostG');
this.trackBy = (index, element) => {
return element.id;
};
}
ngOnInit() {
const elementLowerHost = this.host.querySelector(`.${ElementLowerHostClass}`);
const elementHost = this.host.querySelector(`.${ElementHostClass}`);
const elementUpperHost = this.host.querySelector(`.${ElementUpperHostClass}`);
const elementTopHost = this.host.querySelector(`.${ElementTopHostClass}`);
const roughSVG = rough.svg(this.host, {
options: { roughness: 0, strokeWidth: 1 }
});
this.roughSVG = roughSVG;
this.initializePlugins();
this.ngZone.runOutsideAngular(() => {
this.initializeHookListener();
this.viewportScrollListener();
this.wheelZoomListener();
this.elementResizeListener();
fromEvent(document, 'mouseleave')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT.delete(this.board);
});
});
BOARD_TO_COMPONENT.set(this.board, this);
BOARD_TO_ROUGH_SVG.set(this.board, roughSVG);
BOARD_TO_HOST.set(this.board, this.host);
IS_BOARD_ALIVE.set(this.board, true);
BOARD_TO_ELEMENT_HOST.set(this.board, {
lowerHost: elementLowerHost,
host: elementHost,
upperHost: elementUpperHost,
topHost: elementTopHost,
activeHost: this.activeHostG()?.nativeElement,
container: this.elementRef.nativeElement,
viewportContainer: this.viewportContainer.nativeElement
});
BOARD_TO_ON_CHANGE.set(this.board, () => {
this.ngZone.run(() => {
const isOnlySetSelection = this.board.operations.length && this.board.operations.every((op) => op.type === 'set_selection');
if (isOnlySetSelection) {
this.updateListRender();
return;
}
const isSetViewport = this.board.operations.length && this.board.operations.some((op) => op.type === 'set_viewport');
if (isSetViewport && isFromScrolling(this.board)) {
setIsFromScrolling(this.board, false);
this.updateListRender();
return;
}
this.updateListRender();
if (isSetViewport) {
initializeViewBox(this.board);
}
else {
updateViewBox(this.board);
}
updateViewportOffset(this.board);
const selectedElements = getSelectedElements(this.board);
selectedElements.forEach((element) => {
const elementRef = PlaitElement.getElementRef(element);
elementRef.updateActiveSection();
});
});
});
BOARD_TO_AFTER_CHANGE.set(this.board, () => {
this.ngZone.run(() => {
const data = {
children: this.board.children,
operations: this.board.operations,
viewport: this.board.viewport,
selection: this.board.selection,
theme: this.board.theme
};
this.updateIslands();
this.onChange.emit(data);
});
});
const context = new PlaitBoardContext();
BOARD_TO_CONTEXT.set(this.board, context);
this.initializeListRender();
this.hasInitialized = true;
}
ngAfterContentInit() {
this.initializeIslands();
}
ngOnChanges(changes) {
if (this.hasInitialized) {
const valueChange = changes['plaitValue'];
const options = changes['plaitOptions'];
if (valueChange) {
// avoid useless updating
if (this.board.children !== valueChange.currentValue && !FLUSHING.get(this.board)) {
this.board.children = valueChange.currentValue;
this.updateListRender();
}
}
if (options) {
this.board.options = options.currentValue;
}
this.cdr.markForCheck();
}
}
ngAfterViewInit() {
this.plaitBoardInitialized.emit(this.board);
initializeViewportContainer(this.board);
initializeViewBox(this.board);
initializeViewportOffset(this.board);
}
initializePlugins() {
let board = withRelatedFragment(withHotkey(withHandPointer(withHistory(withSelection(withMoving(withBoard(withI18n(withOptions(withAngular(withImage(withText(createBoard(this.plaitValue, this.plaitOptions)))))))))))));
this.plaitPlugins.forEach((plugin) => {
board = plugin(board);
});
this.board = board;
if (this.plaitViewport) {
this.board.viewport = this.plaitViewport;
}
if (this.plaitTheme) {
this.board.theme = this.plaitTheme;
}
KEY_TO_ELEMENT_MAP.set(board, new Map());
}
initializeHookListener() {
fromEvent(this.host, 'mousedown')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.board.mousedown(event);
});
fromEvent(this.host, 'pointerdown')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.board.pointerDown(event);
});
fromEvent(this.host, 'mousemove')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT_IN_BOARD.set(this.board, [event.x, event.y]);
this.board.mousemove(event);
});
fromEvent(this.host, 'pointermove')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT_IN_BOARD.set(this.board, [event.x, event.y]);
this.board.pointerMove(event);
});
fromEvent(this.host, 'mouseleave')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT_IN_BOARD.delete(this.board);
this.board.mouseleave(event);
});
fromEvent(this.host, 'pointerleave')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT_IN_BOARD.delete(this.board);
this.board.pointerLeave(event);
});
fromEvent(document, 'mousemove')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT.set(this.board, [event.x, event.y]);
this.board.globalMousemove(event);
});
fromEvent(document, 'pointermove')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
BOARD_TO_MOVING_POINT.set(this.board, [event.x, event.y]);
this.board.globalPointerMove(event);
});
fromEvent(this.host, 'mouseup')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.board.mouseup(event);
});
fromEvent(this.host, 'pointerup')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.board.pointerUp(event);
});
fromEvent(document, 'mouseup')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.board.globalMouseup(event);
});
fromEvent(document, 'pointerup')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.board.globalPointerUp(event);
});
fromEvent(this.host, 'dblclick')
.pipe(takeUntil(this.destroy$), filter(() => this.isFocused && !PlaitBoard.hasBeenTextEditing(this.board)))
.subscribe((event) => {
this.board.dblClick(event);
});
fromEvent(document, 'keydown')
.pipe(takeUntil(this.destroy$), tap((event) => {
this.board.globalKeyDown(event);
}), filter((event) => this.isFocused && !PlaitBoard.hasBeenTextEditing(this.board) && !hasInputOrTextareaTarget(event.target)))
.subscribe((event) => {
this.board.keyDown(event);
});
fromEvent(document, 'keyup')
.pipe(takeUntil(this.destroy$), filter(() => this.isFocused && !PlaitBoard.hasBeenTextEditing(this.board)))
.subscribe((event) => {
this.board?.keyUp(event);
});
fromEvent(document, 'copy')
.pipe(takeUntil(this.destroy$), filter(() => this.isFocused && !PlaitBoard.hasBeenTextEditing(this.board)))
.subscribe((event) => {
event.preventDefault();
setFragment(this.board, WritableClipboardOperationType.copy, event.clipboardData);
});
fromEvent(document, 'paste')
.pipe(takeUntil(this.destroy$), filter(() => this.isFocused && !PlaitBoard.isReadonly(this.board) && !PlaitBoard.hasBeenTextEditing(this.board)))
.subscribe(async (clipboardEvent) => {
const mousePoint = PlaitBoard.getMovingPointInBoard(this.board);
if (mousePoint) {
const targetPoint = toViewBoxPoint(this.board, toHostPoint(this.board, mousePoint[0], mousePoint[1]));
const clipboardData = await getClipboardData(clipboardEvent.clipboardData);
this.board.insertFragment(clipboardData, targetPoint, WritableClipboardOperationType.paste);
}
});
fromEvent(document, 'cut')
.pipe(takeUntil(this.destroy$), filter(() => this.isFocused && !PlaitBoard.isReadonly(this.board) && !PlaitBoard.hasBeenTextEditing(this.board)))
.subscribe((event) => {
event.preventDefault();
setFragment(this.board, WritableClipboardOperationType.cut, event.clipboardData);
deleteFragment(this.board);
});
fromEvent(this.host, 'drop')
.pipe(takeUntil(this.destroy$), filter(() => !PlaitBoard.isReadonly(this.board)))
.subscribe((event) => {
event.preventDefault();
this.board.drop(event);
});
fromEvent(this.host, 'dragover')
.pipe(takeUntil(this.destroy$), filter(() => !PlaitBoard.isReadonly(this.board)))
.subscribe((event) => {
event.preventDefault();
});
}
initializeListRender() {
this.listRender = new ListRender(this.board);
this.listRender.initialize(this.board.children, this.initializeChildrenContext());
}
updateListRender() {
this.listRender.update(this.board.children, this.initializeChildrenContext());
PlaitBoard.getBoardContext(this.board).nextStable();
}
initializeChildrenContext() {
return {
board: this.board,
parent: this.board,
parentG: PlaitBoard.getElementHost(this.board)
};
}
viewportScrollListener() {
fromEvent(this.viewportContainer.nativeElement, 'scroll')
.pipe(takeUntil(this.destroy$), filter(() => {
if (isFromViewportChange(this.board)) {
setIsFromViewportChange(this.board, false);
return false;
}
return true;
}))
.subscribe((event) => {
const { scrollLeft, scrollTop } = event.target;
updateViewportByScrolling(this.board, scrollLeft, scrollTop);
});
fromEvent(this.viewportContainer.nativeElement, 'touchstart', { passive: false })
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
event.preventDefault();
});
}
elementResizeListener() {
this.resizeObserver = new ResizeObserver(() => {
initializeViewportContainer(this.board);
initializeViewBox(this.board);
updateViewportOffset(this.board);
});
this.resizeObserver.observe(this.nativeElement);
}
initializeIslands() {
this.islands?.forEach((island) => {
island.initialize(this.board);
});
}
updateIslands() {
this.islands?.forEach((island) => {
if (hasOnBoardChange(island)) {
island.onBoardChange();
}
island.markForCheck();
});
}
wheelZoomListener() {
fromEvent(this.host, 'wheel', { passive: false })
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
// Credits to excalidraw
// https://github.com/excalidraw/excalidraw/blob/b7d7ccc929696cc17b4cc34452e4afd846d59f4f/src/components/App.tsx#L9060
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
const { deltaX, deltaY } = event;
const zoom = this.board.viewport.zoom;
const sign = Math.sign(deltaY);
const MAX_STEP = ZOOM_STEP * 100;
const absDelta = Math.abs(deltaY);
let delta = deltaY;
if (absDelta > MAX_STEP) {
delta = MAX_STEP * sign;
}
let newZoom = zoom - delta / 100;
// increase zoom steps the more zoomed-in we are (applies to >100% only)
newZoom +=
Math.log10(Math.max(1, zoom)) *
-sign *
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
BoardTransforms.updateZoom(this.board, newZoom, PlaitBoard.getMovingPointInBoard(this.board));
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
this.resizeObserver && this.resizeObserver?.disconnect();
BOARD_TO_ROUGH_SVG.delete(this.board);
BOARD_TO_COMPONENT.delete(this.board);
BOARD_TO_ROUGH_SVG.delete(this.board);
BOARD_TO_HOST.delete(this.board);
BOARD_TO_ELEMENT_HOST.delete(this.board);
IS_BOARD_ALIVE.set(this.board, false);
BOARD_TO_ON_CHANGE.delete(this.board);
BOARD_TO_AFTER_CHANGE.set(this.board, () => { });
KEY_TO_ELEMENT_MAP.delete(this.board);
}
markForCheck() {
this.cdr.markForCheck();
this.ngZone.run(() => {
this.updateIslands();
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: PlaitBoardComponent, deps: [{ token: i0.ChangeDetectorRef }, { token: i0.Injector }, { token: i0.ViewContainerRef }, { token: i0.ElementRef }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "19.2.14", type: PlaitBoardComponent, isStandalone: true, selector: "plait-board", inputs: { plaitValue: "plaitValue", plaitViewport: "plaitViewport", plaitPlugins: "plaitPlugins", plaitOptions: "plaitOptions", plaitTheme: "plaitTheme" }, outputs: { onChange: "onChange", plaitBoardInitialized: "plaitBoardInitialized" }, host: { properties: { "class": "this.hostClass", "class.readonly": "this.readonly", "class.focused": "this.isFocused", "class.disabled-scroll": "this.disabledScrollOnNonFocus" } }, queries: [{ propertyName: "islands", predicate: PlaitIslandBaseComponent, descendants: true }], viewQueries: [{ propertyName: "activeHostG", first: true, predicate: ["activeHostG"], descendants: true, isSignal: true }, { propertyName: "svg", first: true, predicate: ["svg"], descendants: true, static: true }, { propertyName: "viewportContainer", first: true, predicate: ["viewportContainer"], descendants: true, read: ElementRef, static: true }], usesOnChanges: true, ngImport: i0, template: `
<div class="viewport-container" #viewportContainer>
<svg #svg width="100%" height="100%" style="position: relative;" class="board-host-svg">
<g class="element-lower-host"></g>
<g class="element-host"></g>
<g class="element-upper-host"></g>
<g class="element-top-host"></g>
</svg>
<svg width="100%" height="100%" class="board-active-svg">
<g #activeHostG class="active-host-g"></g>
</svg>
</div>
<ng-content></ng-content>
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: PlaitBoardComponent, decorators: [{
type: Component,
args: [{
selector: 'plait-board',
template: `
<div class="viewport-container" #viewportContainer>
<svg #svg width="100%" height="100%" style="position: relative;" class="board-host-svg">
<g class="element-lower-host"></g>
<g class="element-host"></g>
<g class="element-upper-host"></g>
<g class="element-top-host"></g>
</svg>
<svg width="100%" height="100%" class="board-active-svg">
<g #activeHostG class="active-host-g"></g>
</svg>
</div>
<ng-content></ng-content>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
}]
}], ctorParameters: () => [{ type: i0.ChangeDetectorRef }, { type: i0.Injector }, { type: i0.ViewContainerRef }, { type: i0.ElementRef }, { type: i0.NgZone }], propDecorators: { plaitValue: [{
type: Input
}], plaitViewport: [{
type: Input
}], plaitPlugins: [{
type: Input
}], plaitOptions: [{
type: Input
}], plaitTheme: [{
type: Input
}], onChange: [{
type: Output
}], plaitBoardInitialized: [{
type: Output
}], hostClass: [{
type: HostBinding,
args: ['class']
}], readonly: [{
type: HostBinding,
args: ['class.readonly']
}], isFocused: [{
type: HostBinding,
args: ['class.focused']
}], disabledScrollOnNonFocus: [{
type: HostBinding,
args: ['class.disabled-scroll']
}], svg: [{
type: ViewChild,
args: ['svg', { static: true }]
}], viewportContainer: [{
type: ViewChild,
args: ['viewportContainer', { read: ElementRef, static: true }]
}], islands: [{
type: ContentChildren,
args: [PlaitIslandBaseComponent, { descendants: true }]
}] } });
const AngularBoard = {
getBoardComponentInjector(board) {
const boardComponent = BOARD_TO_COMPONENT.get(board);
return boardComponent.injector;
}
};
/*
* Public API Surface of plait
*/
/**
* Generated bundle index. Do not edit.
*/
export { AngularBoard, PlaitBoardComponent, PlaitIslandBaseComponent, PlaitIslandPopoverBaseComponent, hasOnBoardChange };
//# sourceMappingURL=plait-angular-board.mjs.map