UNPKG

@plait/angular-board

Version:
616 lines (607 loc) 27.4 kB
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