Initial TrueGrowth source import
This commit is contained in:
12
packages/react-board/.babelrc
Normal file
12
packages/react-board/.babelrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
18
packages/react-board/.eslintrc.json
Normal file
18
packages/react-board/.eslintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
packages/react-board/README.md
Normal file
7
packages/react-board/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# react-board
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test react-board` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
25
packages/react-board/package.json
Normal file
25
packages/react-board/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@plait-board/react-board",
|
||||
"version": "0.2.1",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"private": false,
|
||||
"dependencies": {
|
||||
"@plait/common": "^0.84.0",
|
||||
"@plait/core": "^0.84.0",
|
||||
"@plait/draw": "^0.84.0",
|
||||
"@plait/layouts": "^0.84.0",
|
||||
"@plait/mind": "^0.84.0",
|
||||
"@plait/text-plugins": "^0.84.0",
|
||||
"classnames": "^2.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"slate": "^0.116.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.mjs",
|
||||
"require": "./index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/react-board/project.json
Normal file
17
packages/react-board/project.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "react-board",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/react-board/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"// targets": "to see all targets run: nx show project react-board --web",
|
||||
"targets": {
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "tsc -p tsconfig.lib.json --noEmit",
|
||||
"cwd": "packages/react-board"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
344
packages/react-board/src/board.spec.tsx
Normal file
344
packages/react-board/src/board.spec.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
PlaitElement,
|
||||
clearSelectedElement,
|
||||
getSelectedElements,
|
||||
initializeViewBox,
|
||||
initializeViewportContainer,
|
||||
updateViewportOffset,
|
||||
type Viewport,
|
||||
type PlaitBoard,
|
||||
} from '@plait/core';
|
||||
import React from 'react';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
import { Board } from './board';
|
||||
import { Wrapper } from './wrapper';
|
||||
|
||||
vi.mock('@plait/core', async () => {
|
||||
const actual = await vi.importActual<typeof import('@plait/core')>(
|
||||
'@plait/core'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
PlaitElement: {
|
||||
...actual.PlaitElement,
|
||||
getElementRef: vi.fn(actual.PlaitElement.getElementRef),
|
||||
},
|
||||
getSelectedElements: vi.fn(actual.getSelectedElements),
|
||||
clearSelectedElement: vi.fn(actual.clearSelectedElement),
|
||||
initializeViewportContainer: vi.fn(),
|
||||
initializeViewBox: vi.fn(),
|
||||
initializeViewportOffset: vi.fn(),
|
||||
updateViewportOffset: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
const boardRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1000,
|
||||
bottom: 800,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
|
||||
const mockedInitializeViewportContainer = vi.mocked(
|
||||
initializeViewportContainer
|
||||
);
|
||||
const mockedInitializeViewBox = vi.mocked(initializeViewBox);
|
||||
const mockedUpdateViewportOffset = vi.mocked(updateViewportOffset);
|
||||
const mockedGetSelectedElements = vi.mocked(getSelectedElements);
|
||||
const mockedClearSelectedElement = vi.mocked(clearSelectedElement);
|
||||
const mockedGetElementRef = vi.mocked(PlaitElement.getElementRef);
|
||||
|
||||
const renderBoard = (
|
||||
value: PlaitElement[],
|
||||
viewport?: Viewport,
|
||||
afterInit?: (board: PlaitBoard) => void
|
||||
) => (
|
||||
<Wrapper value={value} viewport={viewport} options={{}} plugins={[]}>
|
||||
<Board afterInit={afterInit} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
describe('ReactBoard', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(
|
||||
boardRect
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGetSelectedElements.mockReturnValue([]);
|
||||
mockedGetElementRef.mockReturnValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('syncs the viewport container after restoring value and viewport props', async () => {
|
||||
const initialValue: PlaitElement[] = [];
|
||||
const restoredValue: PlaitElement[] = [];
|
||||
const restoredViewport = {
|
||||
zoom: 1,
|
||||
origination: [0, 1200],
|
||||
} as Viewport;
|
||||
|
||||
const { rerender } = render(renderBoard(initialValue));
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
rerender(renderBoard(restoredValue, restoredViewport));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedInitializeViewportContainer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockedInitializeViewBox).toHaveBeenCalledTimes(1);
|
||||
expect(mockedUpdateViewportOffset).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockedInitializeViewportContainer.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mockedInitializeViewBox.mock.invocationCallOrder[0]);
|
||||
expect(mockedInitializeViewBox.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedUpdateViewportOffset.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('clears stale history after replacing value props', async () => {
|
||||
const initialValue: PlaitElement[] = [];
|
||||
const restoredValue: PlaitElement[] = [];
|
||||
let board: PlaitBoard | null = null;
|
||||
|
||||
const { rerender } = render(
|
||||
renderBoard(initialValue, undefined, (initializedBoard) => {
|
||||
board = initializedBoard;
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(board).not.toBeNull();
|
||||
});
|
||||
|
||||
board!.history.undos.push([
|
||||
{
|
||||
type: 'remove_node',
|
||||
path: [44],
|
||||
node: { id: 'removed-element' },
|
||||
},
|
||||
]);
|
||||
board!.history.redos.push([
|
||||
{
|
||||
type: 'insert_node',
|
||||
path: [44],
|
||||
node: { id: 'removed-element' },
|
||||
},
|
||||
]);
|
||||
|
||||
rerender(renderBoard(restoredValue));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(board!.history.undos).toHaveLength(0);
|
||||
});
|
||||
expect(board!.history.redos).toHaveLength(0);
|
||||
expect(() => board!.undo()).not.toThrow();
|
||||
});
|
||||
|
||||
it('clears stale selection state after replacing value props', async () => {
|
||||
const initialValue: PlaitElement[] = [];
|
||||
const restoredValue: PlaitElement[] = [];
|
||||
let board: PlaitBoard | null = null;
|
||||
|
||||
const { rerender } = render(
|
||||
renderBoard(initialValue, undefined, (initializedBoard) => {
|
||||
board = initializedBoard;
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(board).not.toBeNull();
|
||||
});
|
||||
|
||||
board!.selection = {
|
||||
anchor: [0, 0],
|
||||
focus: [10, 10],
|
||||
};
|
||||
|
||||
vi.clearAllMocks();
|
||||
rerender(renderBoard(restoredValue));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedClearSelectedElement).toHaveBeenCalledWith(board);
|
||||
});
|
||||
expect(board!.selection).toBeNull();
|
||||
});
|
||||
|
||||
it('syncs the viewport container when only the viewport prop changes', async () => {
|
||||
const value: PlaitElement[] = [];
|
||||
const restoredViewport = {
|
||||
zoom: 1.25,
|
||||
origination: [100, 600],
|
||||
} as Viewport;
|
||||
|
||||
const { rerender } = render(renderBoard(value));
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
rerender(renderBoard(value, restoredViewport));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedInitializeViewportContainer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockedInitializeViewBox).toHaveBeenCalledTimes(1);
|
||||
expect(mockedUpdateViewportOffset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshes selected active sections after viewport prop changes', async () => {
|
||||
const value: PlaitElement[] = [];
|
||||
const selectedElement = { id: 'selected' } as PlaitElement;
|
||||
const elementRef = { updateActiveSection: vi.fn() };
|
||||
const restoredViewport = {
|
||||
zoom: 1.25,
|
||||
origination: [100, 600],
|
||||
} as Viewport;
|
||||
|
||||
mockedGetSelectedElements.mockReturnValue([selectedElement]);
|
||||
mockedGetElementRef.mockReturnValue(elementRef as any);
|
||||
|
||||
const { rerender } = render(renderBoard(value));
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockedGetSelectedElements.mockReturnValue([selectedElement]);
|
||||
mockedGetElementRef.mockReturnValue(elementRef as any);
|
||||
|
||||
rerender(renderBoard(value, restoredViewport));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(elementRef.updateActiveSection).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockedGetElementRef).toHaveBeenCalledWith(selectedElement);
|
||||
});
|
||||
|
||||
it('refreshes selected active sections after restoring viewport scroll', async () => {
|
||||
const value: PlaitElement[] = [];
|
||||
const selectedElement = { id: 'selected' } as PlaitElement;
|
||||
const elementRef = { updateActiveSection: vi.fn() };
|
||||
const restoredViewport = {
|
||||
zoom: 2,
|
||||
origination: [20, 30],
|
||||
} as Viewport;
|
||||
|
||||
mockedGetSelectedElements.mockReturnValue([selectedElement]);
|
||||
mockedGetElementRef.mockReturnValue(elementRef as any);
|
||||
|
||||
const { container, rerender } = render(renderBoard(value));
|
||||
container
|
||||
.querySelector('.board-host-svg')
|
||||
?.setAttribute('viewBox', '0 0 1000 800');
|
||||
const viewportContainer = container.querySelector(
|
||||
'.viewport-container'
|
||||
) as HTMLElement;
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockedGetSelectedElements.mockReturnValue([selectedElement]);
|
||||
mockedGetElementRef.mockReturnValue(elementRef as any);
|
||||
|
||||
rerender(renderBoard(value, restoredViewport));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(viewportContainer.scrollLeft).toBe(40);
|
||||
expect(viewportContainer.scrollTop).toBe(60);
|
||||
});
|
||||
expect(elementRef.updateActiveSection.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('does not refresh element refs when viewport changes without selection', async () => {
|
||||
const value: PlaitElement[] = [];
|
||||
const restoredViewport = {
|
||||
zoom: 1.25,
|
||||
origination: [100, 600],
|
||||
} as Viewport;
|
||||
|
||||
mockedGetSelectedElements.mockReturnValue([]);
|
||||
|
||||
const { rerender } = render(renderBoard(value));
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockedGetSelectedElements.mockReturnValue([]);
|
||||
|
||||
rerender(renderBoard(value, restoredViewport));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedInitializeViewportContainer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockedGetElementRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores paste events from editable targets', async () => {
|
||||
const value: PlaitElement[] = [];
|
||||
let board: PlaitBoard | null = null;
|
||||
const insertFragment = vi.fn();
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
render(
|
||||
renderBoard(value, undefined, (initializedBoard) => {
|
||||
board = initializedBoard;
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(board).not.toBeNull();
|
||||
});
|
||||
if (!board) {
|
||||
throw new Error('Board was not initialized');
|
||||
}
|
||||
|
||||
board.selection = {
|
||||
anchor: [0, 0],
|
||||
focus: [0, 0],
|
||||
};
|
||||
board.insertFragment = insertFragment;
|
||||
|
||||
const pasteEvent = new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}) as ClipboardEvent;
|
||||
Object.defineProperty(pasteEvent, 'clipboardData', {
|
||||
value: { getData: vi.fn().mockReturnValue('pasted prompt') },
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(pasteEvent);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(insertFragment).not.toHaveBeenCalled();
|
||||
textarea.remove();
|
||||
});
|
||||
});
|
||||
161
packages/react-board/src/board.tsx
Normal file
161
packages/react-board/src/board.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import rough from 'roughjs/bin/rough';
|
||||
import {
|
||||
BOARD_TO_AFTER_CHANGE,
|
||||
BOARD_TO_CONTEXT,
|
||||
BOARD_TO_ELEMENT_HOST,
|
||||
BOARD_TO_HOST,
|
||||
BOARD_TO_ON_CHANGE,
|
||||
BOARD_TO_ROUGH_SVG,
|
||||
HOST_CLASS_NAME,
|
||||
IS_BOARD_ALIVE,
|
||||
IS_CHROME,
|
||||
IS_FIREFOX,
|
||||
IS_SAFARI,
|
||||
PlaitBoardContext,
|
||||
initializeViewBox,
|
||||
initializeViewportContainer,
|
||||
initializeViewportOffset,
|
||||
PlaitBoard,
|
||||
KEY_TO_ELEMENT_MAP,
|
||||
} from '@plait/core';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import useBoardPluginEvent from './hooks/use-plugin-event';
|
||||
import useBoardEvent from './hooks/use-board-event';
|
||||
|
||||
import './styles/index.scss';
|
||||
import { useBoard, useListRender } from './hooks/use-board';
|
||||
|
||||
export type PlaitBoardProps = {
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
afterInit?: (board: PlaitBoard) => void;
|
||||
};
|
||||
|
||||
export const Board: React.FC<PlaitBoardProps> = ({
|
||||
style,
|
||||
className,
|
||||
children,
|
||||
afterInit,
|
||||
}) => {
|
||||
const hostRef = useRef<SVGSVGElement>(null);
|
||||
const elementLowerHostRef = useRef<SVGGElement>(null);
|
||||
const elementHostRef = useRef<SVGGElement>(null);
|
||||
const elementUpperHostRef = useRef<SVGGElement>(null);
|
||||
const elementTopHostRef = useRef<SVGGElement>(null);
|
||||
const activeHostRef = useRef<SVGGElement>(null);
|
||||
const viewportContainerRef = useRef<HTMLDivElement>(null);
|
||||
const boardContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const board = useBoard();
|
||||
const listRender = useListRender();
|
||||
|
||||
useEffect(() => {
|
||||
const roughSVG = rough.svg(hostRef.current!, {
|
||||
options: { roughness: 0, strokeWidth: 1 },
|
||||
});
|
||||
BOARD_TO_ROUGH_SVG.set(board, roughSVG);
|
||||
BOARD_TO_HOST.set(board, hostRef.current!);
|
||||
IS_BOARD_ALIVE.set(board, true);
|
||||
BOARD_TO_ELEMENT_HOST.set(board, {
|
||||
lowerHost: elementLowerHostRef.current!,
|
||||
host: elementHostRef.current!,
|
||||
upperHost: elementUpperHostRef.current!,
|
||||
topHost: elementTopHostRef.current!,
|
||||
activeHost: activeHostRef.current!,
|
||||
container: boardContainerRef.current!,
|
||||
viewportContainer: viewportContainerRef.current!,
|
||||
});
|
||||
const context = new PlaitBoardContext();
|
||||
BOARD_TO_CONTEXT.set(board, context);
|
||||
KEY_TO_ELEMENT_MAP.set(board, new Map());
|
||||
|
||||
if (!listRender.initialized) {
|
||||
listRender.initialize(board.children, {
|
||||
board: board,
|
||||
parent: board,
|
||||
parentG: PlaitBoard.getElementHost(board),
|
||||
});
|
||||
if (afterInit) {
|
||||
afterInit(board);
|
||||
}
|
||||
}
|
||||
|
||||
initializeViewportContainer(board);
|
||||
initializeViewBox(board);
|
||||
initializeViewportOffset(board);
|
||||
|
||||
return () => {
|
||||
BOARD_TO_CONTEXT.delete(board);
|
||||
BOARD_TO_AFTER_CHANGE.delete(board);
|
||||
BOARD_TO_ON_CHANGE.delete(board);
|
||||
BOARD_TO_ELEMENT_HOST.delete(board);
|
||||
IS_BOARD_ALIVE.delete(board);
|
||||
BOARD_TO_HOST.delete(board);
|
||||
BOARD_TO_ROUGH_SVG.delete(board);
|
||||
KEY_TO_ELEMENT_MAP.delete(board);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useBoardPluginEvent(board, viewportContainerRef, hostRef);
|
||||
useBoardEvent(board, viewportContainerRef);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
HOST_CLASS_NAME,
|
||||
`${getBrowserClassName()}`,
|
||||
`theme-${board.theme?.themeColorMode}`,
|
||||
`pointer-${board.pointer}`,
|
||||
{
|
||||
focused: PlaitBoard.isFocus(board),
|
||||
readonly: PlaitBoard.isReadonly(board),
|
||||
'disabled-scroll':
|
||||
board.options?.disabledScrollOnNonFocus &&
|
||||
!PlaitBoard.isFocus(board),
|
||||
}
|
||||
)}
|
||||
ref={boardContainerRef}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className="viewport-container"
|
||||
ref={viewportContainerRef}
|
||||
style={{ width: '100%', height: '100%', overflow: 'auto' }}
|
||||
>
|
||||
<svg
|
||||
ref={hostRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ position: 'relative' }}
|
||||
className="board-host-svg"
|
||||
>
|
||||
<g className="element-lower-host" ref={elementLowerHostRef}></g>
|
||||
<g className="element-host" ref={elementHostRef}></g>
|
||||
<g className="element-upper-host" ref={elementUpperHostRef}></g>
|
||||
<g className="element-top-host" ref={elementTopHostRef}></g>
|
||||
</svg>
|
||||
<svg width="100%" height="100%" className="board-active-svg">
|
||||
<g className="active-host-g" ref={activeHostRef}></g>
|
||||
</svg>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getBrowserClassName = () => {
|
||||
if (IS_SAFARI) {
|
||||
return 'safari';
|
||||
}
|
||||
if (IS_CHROME) {
|
||||
return 'chrome';
|
||||
}
|
||||
if (IS_FIREFOX) {
|
||||
return 'firefox';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
99
packages/react-board/src/hooks/use-board-event.ts
Normal file
99
packages/react-board/src/hooks/use-board-event.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
PlaitBoard,
|
||||
ZOOM_STEP,
|
||||
initializeViewBox,
|
||||
initializeViewportContainer,
|
||||
isFromViewportChange,
|
||||
setIsFromViewportChange,
|
||||
updateViewportByScrolling,
|
||||
updateViewportOffset,
|
||||
} from '@plait/core';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
consumeIgnoredViewportScroll,
|
||||
refreshSelectedElementActiveSectionsForViewportChange,
|
||||
updateZoomFromCurrentViewport,
|
||||
} from '../utils/viewport';
|
||||
import { useEventListener } from './use-event-listener';
|
||||
|
||||
const useBoardEvent = (
|
||||
board: PlaitBoard,
|
||||
viewportContainerRef: React.RefObject<HTMLDivElement>
|
||||
) => {
|
||||
useEventListener(
|
||||
'scroll',
|
||||
(event) => {
|
||||
if (consumeIgnoredViewportScroll(board) || isFromViewportChange(board)) {
|
||||
setIsFromViewportChange(board, false);
|
||||
} else {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target) return;
|
||||
const { scrollLeft, scrollTop } = target;
|
||||
updateViewportByScrolling(board, scrollLeft, scrollTop);
|
||||
}
|
||||
},
|
||||
{ target: viewportContainerRef }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'touchstart',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
},
|
||||
{ target: viewportContainerRef, passive: false }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'wheel',
|
||||
(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 = 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);
|
||||
updateZoomFromCurrentViewport(
|
||||
board,
|
||||
newZoom,
|
||||
PlaitBoard.getMovingPointInBoard(board)
|
||||
);
|
||||
}
|
||||
},
|
||||
{ target: viewportContainerRef, passive: false }
|
||||
);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!isInitialized.current) {
|
||||
isInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
initializeViewportContainer(board);
|
||||
initializeViewBox(board);
|
||||
updateViewportOffset(board);
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
});
|
||||
resizeObserver.observe(PlaitBoard.getBoardContainer(board));
|
||||
return () => {
|
||||
resizeObserver && (resizeObserver as ResizeObserver).disconnect();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useBoardEvent;
|
||||
45
packages/react-board/src/hooks/use-board.tsx
Normal file
45
packages/react-board/src/hooks/use-board.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* A React context for sharing the board object, in a way that re-renders the
|
||||
* context whenever changes occur.
|
||||
*/
|
||||
|
||||
import { ListRender, PlaitBoard } from '@plait/core';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface BoardContextValue {
|
||||
v: number;
|
||||
board: PlaitBoard;
|
||||
listRender: ListRender;
|
||||
}
|
||||
|
||||
export const BoardContext = createContext<{
|
||||
v: number;
|
||||
board: PlaitBoard;
|
||||
listRender: ListRender;
|
||||
} | null>(null);
|
||||
|
||||
export const useBoard = (): PlaitBoard => {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`The \`useBoard\` hook must be used inside the <Plait> component's context.`
|
||||
);
|
||||
}
|
||||
|
||||
const { board } = context;
|
||||
return board;
|
||||
};
|
||||
|
||||
export const useListRender = (): ListRender => {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`The \`useBoard\` hook must be used inside the <Plait> component's context.`
|
||||
);
|
||||
}
|
||||
|
||||
const { listRender } = context;
|
||||
return listRender;
|
||||
};
|
||||
63
packages/react-board/src/hooks/use-event-listener.ts
Normal file
63
packages/react-board/src/hooks/use-event-listener.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
type EventListenerTarget =
|
||||
| EventTarget
|
||||
| null
|
||||
| undefined
|
||||
| RefObject<EventTarget | null | undefined>;
|
||||
|
||||
type UseEventListenerOptions = AddEventListenerOptions & {
|
||||
target?: EventListenerTarget;
|
||||
};
|
||||
|
||||
function resolveTarget(target: EventListenerTarget): EventTarget | null {
|
||||
if (!target) {
|
||||
return typeof window === 'undefined' ? null : window;
|
||||
}
|
||||
|
||||
if ('current' in target) {
|
||||
return target.current || null;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export function useEventListener<K extends keyof WindowEventMap>(
|
||||
eventName: K,
|
||||
handler: (event: WindowEventMap[K]) => void,
|
||||
options?: UseEventListenerOptions
|
||||
): void;
|
||||
export function useEventListener(
|
||||
eventName: string,
|
||||
handler: (event: Event) => void,
|
||||
options?: UseEventListenerOptions
|
||||
): void;
|
||||
export function useEventListener(
|
||||
eventName: string,
|
||||
handler: (event: Event) => void,
|
||||
options: UseEventListenerOptions = {}
|
||||
): void {
|
||||
const handlerRef = useRef(handler);
|
||||
const { target, capture, passive, once } = options;
|
||||
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = resolveTarget(target);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listener = (event: Event) => {
|
||||
handlerRef.current(event);
|
||||
};
|
||||
const listenerOptions = { capture, passive, once };
|
||||
|
||||
element.addEventListener(eventName, listener, listenerOptions);
|
||||
return () => {
|
||||
element.removeEventListener(eventName, listener, listenerOptions);
|
||||
};
|
||||
}, [eventName, target, capture, passive, once]);
|
||||
}
|
||||
176
packages/react-board/src/hooks/use-plugin-event.tsx
Normal file
176
packages/react-board/src/hooks/use-plugin-event.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
BOARD_TO_MOVING_POINT,
|
||||
BOARD_TO_MOVING_POINT_IN_BOARD,
|
||||
PlaitBoard,
|
||||
WritableClipboardOperationType,
|
||||
deleteFragment,
|
||||
getClipboardData,
|
||||
hasInputOrTextareaTarget,
|
||||
setFragment,
|
||||
toHostPoint,
|
||||
toViewBoxPoint,
|
||||
} from '@plait/core';
|
||||
import { useEventListener } from './use-event-listener';
|
||||
|
||||
const hasEditableTarget = (target: EventTarget | null) => {
|
||||
if (hasInputOrTextareaTarget(target)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
target instanceof Element && !!target.closest('[contenteditable="true"]')
|
||||
);
|
||||
};
|
||||
|
||||
const useBoardPluginEvent = (
|
||||
board: PlaitBoard,
|
||||
viewportContainerRef: React.RefObject<HTMLDivElement>,
|
||||
hostRef: React.RefObject<SVGSVGElement>
|
||||
) => {
|
||||
useEventListener(
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
board.pointerDown(event);
|
||||
},
|
||||
{ target: hostRef }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'pointermove',
|
||||
(event) => {
|
||||
BOARD_TO_MOVING_POINT_IN_BOARD.set(board, [event.x, event.y]);
|
||||
board.pointerMove(event);
|
||||
},
|
||||
{ target: viewportContainerRef }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'pointerleave',
|
||||
(event) => {
|
||||
BOARD_TO_MOVING_POINT_IN_BOARD.delete(board);
|
||||
board.pointerLeave(event);
|
||||
},
|
||||
{ target: viewportContainerRef }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'pointerup',
|
||||
(event) => {
|
||||
board.pointerUp(event);
|
||||
},
|
||||
{ target: viewportContainerRef }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'dblclick',
|
||||
(event) => {
|
||||
if (PlaitBoard.isFocus(board) && !PlaitBoard.hasBeenTextEditing(board)) {
|
||||
board.dblClick(event);
|
||||
}
|
||||
},
|
||||
{ target: hostRef }
|
||||
);
|
||||
|
||||
useEventListener('pointermove', (event) => {
|
||||
BOARD_TO_MOVING_POINT.set(board, [event.x, event.y]);
|
||||
board.globalPointerMove(event);
|
||||
});
|
||||
|
||||
useEventListener('pointerup', (event) => {
|
||||
board.globalPointerUp(event);
|
||||
});
|
||||
|
||||
useEventListener('keydown', (event) => {
|
||||
board.globalKeyDown(event);
|
||||
if (
|
||||
PlaitBoard.isFocus(board) &&
|
||||
!PlaitBoard.hasBeenTextEditing(board) &&
|
||||
!hasInputOrTextareaTarget(event.target)
|
||||
) {
|
||||
board.keyDown(event);
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('keyup', (event) => {
|
||||
if (PlaitBoard.isFocus(board) && !PlaitBoard.hasBeenTextEditing(board)) {
|
||||
board?.keyUp(event);
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('copy', (event) => {
|
||||
if (
|
||||
PlaitBoard.isFocus(board) &&
|
||||
!PlaitBoard.hasBeenTextEditing(board) &&
|
||||
!hasEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
setFragment(
|
||||
board,
|
||||
WritableClipboardOperationType.copy,
|
||||
event.clipboardData
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('paste', async (clipboardEvent) => {
|
||||
if (
|
||||
PlaitBoard.isFocus(board) &&
|
||||
!PlaitBoard.isReadonly(board) &&
|
||||
!PlaitBoard.hasBeenTextEditing(board) &&
|
||||
!hasEditableTarget(clipboardEvent.target)
|
||||
) {
|
||||
const mousePoint = PlaitBoard.getMovingPointInBoard(board);
|
||||
if (mousePoint) {
|
||||
const targetPoint = toViewBoxPoint(
|
||||
board,
|
||||
toHostPoint(board, mousePoint[0], mousePoint[1])
|
||||
);
|
||||
const clipboardData = await getClipboardData(
|
||||
clipboardEvent.clipboardData
|
||||
);
|
||||
board.insertFragment(
|
||||
clipboardData,
|
||||
targetPoint,
|
||||
WritableClipboardOperationType.paste
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('cut', (event) => {
|
||||
if (
|
||||
PlaitBoard.isFocus(board) &&
|
||||
!PlaitBoard.isReadonly(board) &&
|
||||
!PlaitBoard.hasBeenTextEditing(board) &&
|
||||
!hasEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
setFragment(
|
||||
board,
|
||||
WritableClipboardOperationType.cut,
|
||||
event.clipboardData
|
||||
);
|
||||
deleteFragment(board);
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener(
|
||||
'drop',
|
||||
(event) => {
|
||||
if (!PlaitBoard.isReadonly(board)) {
|
||||
event.preventDefault();
|
||||
board.drop(event);
|
||||
}
|
||||
},
|
||||
{ target: viewportContainerRef }
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
'dragover',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
},
|
||||
{ target: viewportContainerRef }
|
||||
);
|
||||
};
|
||||
|
||||
export default useBoardPluginEvent;
|
||||
4
packages/react-board/src/index.ts
Normal file
4
packages/react-board/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './board';
|
||||
export * from './plugins/board';
|
||||
export * from './wrapper';
|
||||
export * from './hooks/use-board';
|
||||
24
packages/react-board/src/plugins/board.ts
Normal file
24
packages/react-board/src/plugins/board.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { RenderComponentRef } from '@plait/common';
|
||||
import {
|
||||
PlaitElement,
|
||||
PlaitOperation,
|
||||
Viewport,
|
||||
Selection,
|
||||
type PlaitTheme
|
||||
} from '@plait/core';
|
||||
|
||||
export interface ReactBoard {
|
||||
renderComponent: <T extends object>(
|
||||
children: React.ReactNode,
|
||||
container: Element | DocumentFragment,
|
||||
props: T
|
||||
) => RenderComponentRef<T>;
|
||||
}
|
||||
|
||||
export interface BoardChangeData {
|
||||
children: PlaitElement[];
|
||||
operations: PlaitOperation[];
|
||||
viewport: Viewport;
|
||||
selection: Selection | null;
|
||||
theme: PlaitTheme;
|
||||
}
|
||||
177
packages/react-board/src/plugins/with-pinch-zoom-plugin.ts
Normal file
177
packages/react-board/src/plugins/with-pinch-zoom-plugin.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
BoardTransforms,
|
||||
distanceBetweenPointAndPoint,
|
||||
getPointBetween,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
PlaitBoard,
|
||||
Point,
|
||||
} from '@plait/core';
|
||||
import { getCurrentViewportOrigination } from '../utils/viewport';
|
||||
|
||||
interface PointerRecord {
|
||||
pointerId: number;
|
||||
lastPoint: Point;
|
||||
currentPoint: Point;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export const withPinchZoom = (board: PlaitBoard) => {
|
||||
const { pointerDown, pointerMove, pointerUp, globalPointerUp } = board;
|
||||
|
||||
const pointerRecords: PointerRecord[] = [];
|
||||
let initializeZoom = 0;
|
||||
let isPinching = false;
|
||||
|
||||
board.pointerDown = (event: PointerEvent) => {
|
||||
const point: Point = [event.clientX, event.clientY];
|
||||
|
||||
if (pointerRecords.length < 2) {
|
||||
initializeZoom = board.viewport.zoom;
|
||||
pointerRecords.push({
|
||||
pointerId: event.pointerId,
|
||||
lastPoint: point,
|
||||
currentPoint: point,
|
||||
hasMoved: false,
|
||||
});
|
||||
}
|
||||
|
||||
pointerDown(event);
|
||||
};
|
||||
|
||||
board.pointerMove = (event: PointerEvent) => {
|
||||
const point: Point = [event.clientX, event.clientY];
|
||||
|
||||
if (pointerRecords.length >= 2) {
|
||||
const record = pointerRecords.find(
|
||||
(r) => r.pointerId === event.pointerId
|
||||
);
|
||||
if (record) {
|
||||
record.currentPoint = point;
|
||||
record.hasMoved = true;
|
||||
|
||||
// 检查是否两个触控点都移动过
|
||||
if (
|
||||
pointerRecords.length === 2 &&
|
||||
pointerRecords.every((r) => r.hasMoved)
|
||||
) {
|
||||
const p1 = pointerRecords[0]!;
|
||||
const p2 = pointerRecords[1]!;
|
||||
const pinchCenter = getPointBetween(
|
||||
...p1.lastPoint,
|
||||
...p2.lastPoint
|
||||
) as Point;
|
||||
const newPinchCenter = getPointBetween(
|
||||
...p1.currentPoint,
|
||||
...p2.currentPoint
|
||||
) as Point;
|
||||
const dx = newPinchCenter[0] - pinchCenter[0];
|
||||
const dy = newPinchCenter[1] - pinchCenter[1];
|
||||
|
||||
// hand moving
|
||||
const boardContainerRect =
|
||||
PlaitBoard.getBoardContainer(board).getBoundingClientRect();
|
||||
const halfOfWidth = boardContainerRect.width / 2;
|
||||
const halfOfHeight = boardContainerRect.height / 2;
|
||||
const zoom = board.viewport.zoom;
|
||||
const origination = getCurrentViewportOrigination(board);
|
||||
let centerX = origination[0] + halfOfWidth / zoom - dx / zoom;
|
||||
let centerY = origination[1] + halfOfHeight / zoom - dy / zoom;
|
||||
let newOrigination = [
|
||||
centerX - boardContainerRect.width / 2 / zoom,
|
||||
centerY - boardContainerRect.height / 2 / zoom,
|
||||
] as Point;
|
||||
|
||||
let newZoom = zoom;
|
||||
const lastDistance = distanceBetweenPointAndPoint(
|
||||
...p1.lastPoint,
|
||||
...p2.lastPoint
|
||||
);
|
||||
const currentDistance = distanceBetweenPointAndPoint(
|
||||
...p1.currentPoint,
|
||||
...p2.currentPoint
|
||||
);
|
||||
// zoom 处理
|
||||
const scale = currentDistance / lastDistance;
|
||||
|
||||
const v1 = [
|
||||
p1.currentPoint[0] - p1.lastPoint[0],
|
||||
p1.currentPoint[1] - p1.lastPoint[1],
|
||||
] as Point;
|
||||
const v2 = [
|
||||
p2.currentPoint[0] - p2.lastPoint[0],
|
||||
p2.currentPoint[1] - p2.lastPoint[1],
|
||||
] as Point;
|
||||
|
||||
const dotProduct = v1[0] * v2[0] + v1[1] * v2[1];
|
||||
const v1Magnitude = Math.sqrt(v1[0] * v1[0] + v1[1] * v1[1]);
|
||||
const v2Magnitude = Math.sqrt(v2[0] * v2[0] + v2[1] * v2[1]);
|
||||
|
||||
const cosTheta = dotProduct / (v1Magnitude * v2Magnitude || 1);
|
||||
|
||||
// 控制缩放
|
||||
// 基于余弦相似度(Cosine Similarity)
|
||||
// 余弦值 > 0.8:判定为平移手势(向量基本同向)
|
||||
// 余弦值 < -0.7:判定为缩放手势(向量基本反向)
|
||||
// 其他情况:未知手势
|
||||
if (
|
||||
cosTheta < -0.7 ||
|
||||
(cosTheta <= 0.8 && isPinching && scale >= 0.01)
|
||||
) {
|
||||
isPinching = true;
|
||||
} else {
|
||||
isPinching = false;
|
||||
}
|
||||
if (isPinching) {
|
||||
newZoom = Math.min(
|
||||
Math.max(board.viewport.zoom * scale, MIN_ZOOM),
|
||||
MAX_ZOOM
|
||||
);
|
||||
const nativeElement = PlaitBoard.getBoardContainer(board);
|
||||
const nativeElementRect = nativeElement.getBoundingClientRect();
|
||||
const zoomCenterWidth = newPinchCenter[0] - nativeElementRect.x;
|
||||
const zoomCenterHeight = newPinchCenter[1] - nativeElementRect.y;
|
||||
centerX = newOrigination[0] + zoomCenterWidth / zoom;
|
||||
centerY = newOrigination[1] + zoomCenterHeight / zoom;
|
||||
newOrigination = [
|
||||
centerX - zoomCenterWidth / newZoom,
|
||||
centerY - zoomCenterHeight / newZoom,
|
||||
] as Point;
|
||||
}
|
||||
BoardTransforms.updateViewport(board, newOrigination, newZoom);
|
||||
pointerRecords.forEach((r) => {
|
||||
r.lastPoint = r.currentPoint;
|
||||
r.hasMoved = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pointerMove(event);
|
||||
};
|
||||
|
||||
board.pointerUp = (event: PointerEvent) => {
|
||||
const index = pointerRecords.findIndex(
|
||||
(r) => r.pointerId === event.pointerId
|
||||
);
|
||||
if (index !== -1) {
|
||||
pointerRecords.splice(index, 1);
|
||||
}
|
||||
|
||||
pointerUp(event);
|
||||
};
|
||||
|
||||
board.globalPointerUp = (event: PointerEvent) => {
|
||||
const index = pointerRecords.findIndex(
|
||||
(r) => r.pointerId === event.pointerId
|
||||
);
|
||||
if (index !== -1) {
|
||||
pointerRecords.splice(index, 1);
|
||||
}
|
||||
|
||||
globalPointerUp(event);
|
||||
};
|
||||
|
||||
return board;
|
||||
};
|
||||
106
packages/react-board/src/plugins/with-react.tsx
Normal file
106
packages/react-board/src/plugins/with-react.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
type PlaitTextBoard,
|
||||
type RenderComponentRef,
|
||||
type TextProps,
|
||||
} from '@plait/common';
|
||||
import type { PlaitBoard } from '@plait/core';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Text, type CustomEditor } from '@plait-board/react-text';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Node as SlateNode, Transforms } from 'slate';
|
||||
import type { ReactBoard } from './board';
|
||||
|
||||
export const withReact = (board: PlaitBoard & PlaitTextBoard) => {
|
||||
const newBoard = board as PlaitBoard & PlaitTextBoard & ReactBoard;
|
||||
|
||||
newBoard.renderText = (
|
||||
container: Element | DocumentFragment,
|
||||
props: TextProps
|
||||
) => {
|
||||
const root = createRoot(container);
|
||||
let currentEditor: CustomEditor;
|
||||
let destroyed = false;
|
||||
let focusTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const clearFocusTimer = () => {
|
||||
if (focusTimer) {
|
||||
clearTimeout(focusTimer);
|
||||
focusTimer = undefined;
|
||||
}
|
||||
};
|
||||
const text = (
|
||||
<Text
|
||||
{...props}
|
||||
afterInit={(editor) => {
|
||||
currentEditor = editor as CustomEditor;
|
||||
props.afterInit && props.afterInit(currentEditor);
|
||||
}}
|
||||
></Text>
|
||||
);
|
||||
root.render(text);
|
||||
let newProps = { ...props };
|
||||
const ref: RenderComponentRef<TextProps> = {
|
||||
destroy: () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
destroyed = true;
|
||||
clearFocusTimer();
|
||||
setTimeout(() => {
|
||||
root.unmount();
|
||||
}, 0);
|
||||
},
|
||||
update: (updatedProps: Partial<TextProps>) => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
const hasUpdated =
|
||||
updatedProps &&
|
||||
newProps &&
|
||||
!Object.keys(updatedProps).every(
|
||||
(key) =>
|
||||
updatedProps[key as keyof TextProps] ===
|
||||
newProps[key as keyof TextProps]
|
||||
);
|
||||
if (!hasUpdated) {
|
||||
return;
|
||||
}
|
||||
const readonly = ReactEditor.isReadOnly(currentEditor);
|
||||
newProps = { ...newProps, ...updatedProps };
|
||||
root.render(<Text {...newProps}></Text>);
|
||||
|
||||
if (readonly === true && newProps.readonly === false) {
|
||||
clearFocusTimer();
|
||||
focusTimer = setTimeout(() => {
|
||||
focusTimer = undefined;
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
ensureValidSelection(currentEditor);
|
||||
ReactEditor.focus(currentEditor);
|
||||
}, 0);
|
||||
} else if (readonly === false && newProps.readonly === true) {
|
||||
clearFocusTimer();
|
||||
ReactEditor.blur(currentEditor);
|
||||
ReactEditor.deselect(currentEditor);
|
||||
}
|
||||
},
|
||||
};
|
||||
return ref;
|
||||
};
|
||||
|
||||
return newBoard;
|
||||
};
|
||||
|
||||
const ensureValidSelection = (editor: CustomEditor) => {
|
||||
const { selection } = editor;
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
SlateNode.has(editor, selection.anchor.path) &&
|
||||
SlateNode.has(editor, selection.focus.path)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Transforms.deselect(editor);
|
||||
};
|
||||
124
packages/react-board/src/styles/index.scss
Normal file
124
packages/react-board/src/styles/index.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@use './mixins.scss' as mixins;
|
||||
|
||||
.plait-board-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
foreignObject {
|
||||
outline: none;
|
||||
}
|
||||
// safari can not set this style, it will prevent text being from selected in edit mode
|
||||
// resolve the issue text being selected when user drag and move on board in firefox
|
||||
&.firefox {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.viewport-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
// 空画布也有约 2 倍视口的可滚动区(Plait 最小 viewBox);隐藏原生条,平移靠滚轮/手掌工具。
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled-scroll {
|
||||
.viewport-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
transform: matrix(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/51313873/svg-foreignobject-not-working-properly-on-safari
|
||||
.plait-text-container {
|
||||
// chrome show position is not correct, safari not working when don't assigned position property
|
||||
// can not assign absolute, because safari can not show correctly position
|
||||
position: initial !important;
|
||||
}
|
||||
|
||||
.text {
|
||||
foreignObject {
|
||||
outline: none;
|
||||
}
|
||||
.slate-editable-container {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plait-toolbar {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
height: 30px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&.element-moving {
|
||||
.element-active-host {
|
||||
& > g:not(.active-with-moving) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.element-rotating {
|
||||
.element-active-host {
|
||||
g.resize-handle,
|
||||
g[class^='line-auto-complete-'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.pointer-selection {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.viewport-moving,
|
||||
&.viewport-moving .board-host-svg {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
&.viewport-moving:active,
|
||||
&.viewport-moving:active .board-host-svg {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
&.ns-resize {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
&.ew-resize {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
&.nwse-resize {
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
&.nesw-resize {
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
&.crosshair {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
foreignObject[class^='foreign-object-'] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.board-active-svg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@include mixins.board-background-color();
|
||||
}
|
||||
18
packages/react-board/src/styles/mixins.scss
Normal file
18
packages/react-board/src/styles/mixins.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@mixin board-background-color {
|
||||
&.theme-colorful .board-host-svg,
|
||||
&.theme-default .board-host-svg {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
&.theme-soft .board-host-svg {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
&.theme-retro .board-host-svg {
|
||||
background-color: #f9f8ed;
|
||||
}
|
||||
&.theme-dark .board-host-svg {
|
||||
background-color: #141414;
|
||||
}
|
||||
&.theme-starry .board-host-svg {
|
||||
background-color: #0d2537;
|
||||
}
|
||||
}
|
||||
188
packages/react-board/src/utils/viewport.ts
Normal file
188
packages/react-board/src/utils/viewport.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BoardTransforms,
|
||||
PlaitBoard,
|
||||
PlaitElement,
|
||||
Point,
|
||||
clampZoomLevel,
|
||||
getViewBox,
|
||||
getSelectedElements,
|
||||
getViewportOrigination,
|
||||
} from '@plait/core';
|
||||
import type { PlaitCommonElementRef } from '@plait/common';
|
||||
|
||||
const IGNORED_VIEWPORT_SCROLL_COUNT = new WeakMap<PlaitBoard, number>();
|
||||
const IGNORED_VIEWPORT_SCROLL_TIMEOUT = new WeakMap<
|
||||
PlaitBoard,
|
||||
ReturnType<typeof setTimeout>
|
||||
>();
|
||||
const ACTIVE_SECTION_REFRESH_PENDING = new WeakMap<PlaitBoard, boolean>();
|
||||
|
||||
export const getCurrentViewportOrigination = (board: PlaitBoard): Point => {
|
||||
const originFromScroll = getViewportOriginationFromScroll(board);
|
||||
if (originFromScroll) {
|
||||
return originFromScroll;
|
||||
}
|
||||
|
||||
return getViewportOrigination(board) || board.viewport.origination || [0, 0];
|
||||
};
|
||||
|
||||
export const updateZoomFromCurrentViewport = (
|
||||
board: PlaitBoard,
|
||||
newZoom: number,
|
||||
center?: Point
|
||||
) => {
|
||||
const zoom = board.viewport.zoom;
|
||||
const origination = getCurrentViewportOrigination(board);
|
||||
const boardContainerRect = PlaitBoard.getBoardContainer(
|
||||
board
|
||||
).getBoundingClientRect();
|
||||
const focusPoint = getFocusPoint(boardContainerRect, center);
|
||||
const nextZoom = clampZoomLevel(newZoom);
|
||||
const centerX = origination[0] + focusPoint[0] / zoom;
|
||||
const centerY = origination[1] + focusPoint[1] / zoom;
|
||||
|
||||
BoardTransforms.updateViewport(
|
||||
board,
|
||||
[
|
||||
centerX - focusPoint[0] / nextZoom,
|
||||
centerY - focusPoint[1] / nextZoom,
|
||||
],
|
||||
nextZoom
|
||||
);
|
||||
};
|
||||
|
||||
export const ignoreUpcomingViewportScroll = (
|
||||
board: PlaitBoard,
|
||||
count = 1,
|
||||
timeout = 120
|
||||
) => {
|
||||
IGNORED_VIEWPORT_SCROLL_COUNT.set(board, count);
|
||||
const pendingTimeout = IGNORED_VIEWPORT_SCROLL_TIMEOUT.get(board);
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
}
|
||||
IGNORED_VIEWPORT_SCROLL_TIMEOUT.set(
|
||||
board,
|
||||
setTimeout(() => {
|
||||
IGNORED_VIEWPORT_SCROLL_COUNT.delete(board);
|
||||
IGNORED_VIEWPORT_SCROLL_TIMEOUT.delete(board);
|
||||
}, timeout)
|
||||
);
|
||||
};
|
||||
|
||||
export const consumeIgnoredViewportScroll = (board: PlaitBoard) => {
|
||||
const count = IGNORED_VIEWPORT_SCROLL_COUNT.get(board) ?? 0;
|
||||
if (count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
IGNORED_VIEWPORT_SCROLL_COUNT.delete(board);
|
||||
const pendingTimeout = IGNORED_VIEWPORT_SCROLL_TIMEOUT.get(board);
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
IGNORED_VIEWPORT_SCROLL_TIMEOUT.delete(board);
|
||||
}
|
||||
} else {
|
||||
IGNORED_VIEWPORT_SCROLL_COUNT.set(board, count - 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const refreshSelectedElementActiveSections = (board: PlaitBoard) => {
|
||||
const selectedElements = getSelectedElements(board);
|
||||
selectedElements.forEach((element) => {
|
||||
const elementRef =
|
||||
PlaitElement.getElementRef<PlaitCommonElementRef>(element);
|
||||
elementRef?.updateActiveSection();
|
||||
});
|
||||
return selectedElements.length;
|
||||
};
|
||||
|
||||
export const scheduleSelectedElementActiveSectionRefresh = (
|
||||
board: PlaitBoard
|
||||
) => {
|
||||
if (ACTIVE_SECTION_REFRESH_PENDING.get(board)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getSelectedElements(board).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ACTIVE_SECTION_REFRESH_PENDING.set(board, true);
|
||||
scheduleFrame(() => {
|
||||
ACTIVE_SECTION_REFRESH_PENDING.delete(board);
|
||||
refreshSelectedElementActiveSections(board);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const refreshSelectedElementActiveSectionsForViewportChange = (
|
||||
board: PlaitBoard
|
||||
) => {
|
||||
const count = refreshSelectedElementActiveSections(board);
|
||||
if (count > 0) {
|
||||
scheduleSelectedElementActiveSectionRefresh(board);
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const getViewportOriginationFromScroll = (board: PlaitBoard): Point | null => {
|
||||
const zoom = board.viewport.zoom;
|
||||
if (!Number.isFinite(zoom) || zoom <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const viewportContainer = PlaitBoard.getViewportContainer(board);
|
||||
const viewBox = getViewBox(board);
|
||||
if (
|
||||
!viewBox ||
|
||||
!Number.isFinite(viewBox.x) ||
|
||||
!Number.isFinite(viewBox.y) ||
|
||||
!Number.isFinite(viewBox.width) ||
|
||||
!Number.isFinite(viewBox.height) ||
|
||||
viewBox.width <= 0 ||
|
||||
viewBox.height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
viewportContainer.scrollLeft / zoom + viewBox.x,
|
||||
viewportContainer.scrollTop / zoom + viewBox.y,
|
||||
];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getFocusPoint = (boardContainerRect: DOMRect, center?: Point): Point => {
|
||||
if (center && isPointInRect(center, boardContainerRect)) {
|
||||
return [center[0] - boardContainerRect.x, center[1] - boardContainerRect.y];
|
||||
}
|
||||
|
||||
return [boardContainerRect.width / 2, boardContainerRect.height / 2];
|
||||
};
|
||||
|
||||
const isPointInRect = (point: Point, rect: DOMRect) => {
|
||||
return (
|
||||
point[0] >= rect.left &&
|
||||
point[0] <= rect.right &&
|
||||
point[1] >= rect.top &&
|
||||
point[1] <= rect.bottom
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleFrame = (callback: () => void) => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.requestAnimationFrame === 'function'
|
||||
) {
|
||||
window.requestAnimationFrame(callback);
|
||||
return;
|
||||
}
|
||||
setTimeout(callback, 0);
|
||||
};
|
||||
610
packages/react-board/src/wrapper.tsx
Normal file
610
packages/react-board/src/wrapper.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
import {
|
||||
BOARD_TO_ON_CHANGE,
|
||||
ListRender,
|
||||
PlaitElement,
|
||||
Viewport,
|
||||
createBoard,
|
||||
withBoard,
|
||||
withHandPointer,
|
||||
withHistory,
|
||||
withHotkey,
|
||||
withMoving,
|
||||
withOptions,
|
||||
withRelatedFragment,
|
||||
withSelection,
|
||||
PlaitBoard,
|
||||
type PlaitPlugin,
|
||||
type PlaitBoardOptions,
|
||||
type Selection,
|
||||
ThemeColorMode,
|
||||
BOARD_TO_AFTER_CHANGE,
|
||||
PlaitOperation,
|
||||
PlaitTheme,
|
||||
isFromScrolling,
|
||||
setIsFromScrolling,
|
||||
setIsFromViewportChange,
|
||||
updateViewportOffset,
|
||||
initializeViewBox,
|
||||
initializeViewportContainer,
|
||||
withI18n,
|
||||
updateViewBox,
|
||||
FLUSHING,
|
||||
BoardTransforms,
|
||||
clearSelectedElement,
|
||||
} from '@plait/core';
|
||||
import { BoardChangeData } from './plugins/board';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { withReact } from './plugins/with-react';
|
||||
import { withImage, withText } from '@plait/common';
|
||||
import { BoardContext, BoardContextValue } from './hooks/use-board';
|
||||
import React from 'react';
|
||||
import { withPinchZoom } from './plugins/with-pinch-zoom-plugin';
|
||||
import {
|
||||
ignoreUpcomingViewportScroll,
|
||||
refreshSelectedElementActiveSections,
|
||||
refreshSelectedElementActiveSectionsForViewportChange,
|
||||
} from './utils/viewport';
|
||||
|
||||
export type WrapperProps = {
|
||||
value: PlaitElement[];
|
||||
children: React.ReactNode;
|
||||
options: PlaitBoardOptions;
|
||||
plugins: PlaitPlugin[];
|
||||
viewport?: Viewport;
|
||||
theme?: PlaitTheme;
|
||||
onChange?: (data: BoardChangeData) => void;
|
||||
onSelectionChange?: (selection: Selection | null) => void;
|
||||
onValueChange?: (value: PlaitElement[]) => void;
|
||||
onViewportChange?: (value: Viewport) => void;
|
||||
onThemeChange?: (value: ThemeColorMode) => void;
|
||||
};
|
||||
|
||||
export const Wrapper: React.FC<WrapperProps> = ({
|
||||
value,
|
||||
children,
|
||||
options,
|
||||
plugins,
|
||||
viewport,
|
||||
theme,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
onValueChange,
|
||||
onViewportChange,
|
||||
onThemeChange,
|
||||
}) => {
|
||||
const [context, setContext] = useState<BoardContextValue>(() => {
|
||||
const board = initializeBoard(value, options, plugins, viewport, theme);
|
||||
const listRender = initializeListRender(board);
|
||||
return {
|
||||
v: 0,
|
||||
board,
|
||||
listRender,
|
||||
};
|
||||
});
|
||||
|
||||
const { board, listRender } = context;
|
||||
|
||||
const onContextChange = useCallback(() => {
|
||||
if (onChange) {
|
||||
const data: BoardChangeData = {
|
||||
children: board.children,
|
||||
operations: board.operations,
|
||||
viewport: board.viewport,
|
||||
selection: board.selection,
|
||||
theme: board.theme,
|
||||
};
|
||||
onChange(data);
|
||||
}
|
||||
|
||||
const hasSelectionChanged = board.operations.some((o) =>
|
||||
PlaitOperation.isSetSelectionOperation(o)
|
||||
);
|
||||
const hasViewportChanged = board.operations.some((o) =>
|
||||
PlaitOperation.isSetViewportOperation(o)
|
||||
);
|
||||
const hasThemeChanged = board.operations.some((o) =>
|
||||
PlaitOperation.isSetThemeOperation(o)
|
||||
);
|
||||
const hasChildrenChanged =
|
||||
board.operations.length > 0 &&
|
||||
!board.operations.every(
|
||||
(o) =>
|
||||
PlaitOperation.isSetSelectionOperation(o) ||
|
||||
PlaitOperation.isSetViewportOperation(o) ||
|
||||
PlaitOperation.isSetThemeOperation(o)
|
||||
);
|
||||
|
||||
if (onValueChange && hasChildrenChanged) {
|
||||
onValueChange(board.children);
|
||||
}
|
||||
|
||||
if (onSelectionChange && hasSelectionChanged) {
|
||||
onSelectionChange(board.selection);
|
||||
}
|
||||
|
||||
if (onViewportChange && hasViewportChanged) {
|
||||
onViewportChange(board.viewport);
|
||||
}
|
||||
|
||||
if (onThemeChange && hasThemeChanged) {
|
||||
onThemeChange(board.theme.themeColorMode);
|
||||
}
|
||||
|
||||
setContext((prevContext) => ({
|
||||
v: prevContext.v + 1,
|
||||
board,
|
||||
listRender,
|
||||
}));
|
||||
}, [
|
||||
board,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
onValueChange,
|
||||
onViewportChange,
|
||||
onThemeChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
BOARD_TO_ON_CHANGE.set(board, () => {
|
||||
const isOnlySetSelection =
|
||||
board.operations.length &&
|
||||
board.operations.every((op) => op.type === 'set_selection');
|
||||
if (isOnlySetSelection) {
|
||||
listRender.update(board.children, {
|
||||
board: board,
|
||||
parent: board,
|
||||
parentG: PlaitBoard.getElementHost(board),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isSetViewport =
|
||||
board.operations.length &&
|
||||
board.operations.some((op) => op.type === 'set_viewport');
|
||||
if (isSetViewport && isFromScrolling(board)) {
|
||||
setIsFromScrolling(board, false);
|
||||
listRender.update(board.children, {
|
||||
board: board,
|
||||
parent: board,
|
||||
parentG: PlaitBoard.getElementHost(board),
|
||||
});
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
return;
|
||||
}
|
||||
listRender.update(board.children, {
|
||||
board: board,
|
||||
parent: board,
|
||||
parentG: PlaitBoard.getElementHost(board),
|
||||
});
|
||||
if (isSetViewport) {
|
||||
initializeViewBox(board);
|
||||
} else {
|
||||
updateViewBox(board);
|
||||
}
|
||||
updateViewportOffset(board);
|
||||
if (isSetViewport) {
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
} else {
|
||||
refreshSelectedElementActiveSections(board);
|
||||
}
|
||||
});
|
||||
|
||||
BOARD_TO_AFTER_CHANGE.set(board, () => {
|
||||
onContextChange();
|
||||
});
|
||||
|
||||
return () => {
|
||||
BOARD_TO_ON_CHANGE.delete(board);
|
||||
BOARD_TO_AFTER_CHANGE.delete(board);
|
||||
};
|
||||
}, [board]);
|
||||
|
||||
const isFirstRender = useRef(true);
|
||||
const prevViewportRef = useRef<Viewport | undefined>(viewport);
|
||||
|
||||
// 处理 viewport prop 变化(用于恢复保存的视图状态)
|
||||
useEffect(() => {
|
||||
const prevViewport = prevViewportRef.current;
|
||||
prevViewportRef.current = viewport;
|
||||
|
||||
// 如果本次同时替换 children,等待 children 更新后再同步视口,避免用旧内容计算滚动范围
|
||||
const hasPendingValueUpdate = value !== board.children;
|
||||
|
||||
// 如果外部传入了有效的 viewport,且与当前不同,则应用它
|
||||
if (viewport && !FLUSHING.get(board) && !hasPendingValueUpdate) {
|
||||
if (!isSameViewport(prevViewport, viewport)) {
|
||||
board.viewport = viewport;
|
||||
syncViewportContainer(board, 'viewport-prop-change');
|
||||
}
|
||||
}
|
||||
}, [viewport, board, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== context.board.children && !FLUSHING.get(board)) {
|
||||
board.children = value;
|
||||
resetBoardHistory(board);
|
||||
clearSelectedElement(board);
|
||||
board.selection = null;
|
||||
listRender.update(board.children, {
|
||||
board: board,
|
||||
parent: board,
|
||||
parentG: PlaitBoard.getElementHost(board),
|
||||
});
|
||||
// 只有当没有传入 viewport 时才自动适配视图
|
||||
// 如果传入了 viewport,说明是恢复保存的视图,不应该重置
|
||||
if (!viewport) {
|
||||
BoardTransforms.fitViewport(board);
|
||||
} else {
|
||||
board.viewport = viewport;
|
||||
syncViewportContainer(board, 'value-with-viewport-change');
|
||||
}
|
||||
}
|
||||
}, [value, viewport]);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider value={context}>{children}</BoardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const initializeBoard = (
|
||||
value: PlaitElement[],
|
||||
options: PlaitBoardOptions,
|
||||
plugins: PlaitPlugin[],
|
||||
viewport?: Viewport,
|
||||
theme?: PlaitTheme
|
||||
) => {
|
||||
let board = withRelatedFragment(
|
||||
withHotkey(
|
||||
withHandPointer(
|
||||
withHistory(
|
||||
withSelection(
|
||||
withMoving(
|
||||
withBoard(
|
||||
withI18n(
|
||||
withOptions(
|
||||
withReact(withImage(withText(createBoard(value, options))))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
plugins.forEach((plugin: any) => {
|
||||
board = plugin(board);
|
||||
});
|
||||
withPinchZoom(board);
|
||||
|
||||
if (viewport) {
|
||||
board.viewport = viewport;
|
||||
}
|
||||
|
||||
if (theme) {
|
||||
board.theme = theme;
|
||||
}
|
||||
|
||||
return board;
|
||||
};
|
||||
|
||||
const resetBoardHistory = (board: PlaitBoard) => {
|
||||
board.history.undos = [];
|
||||
board.history.redos = [];
|
||||
};
|
||||
|
||||
const initializeListRender = (board: PlaitBoard) => {
|
||||
const listRender = new ListRender(board);
|
||||
return listRender;
|
||||
};
|
||||
|
||||
const VIEWPORT_RESTORE_FRAMES = 30;
|
||||
const VIEWPORT_RESTORE_MIN_FRAMES = 6;
|
||||
const VIEWPORT_RESTORE_TOLERANCE = 2;
|
||||
const VIEWPORT_RESTORE_VERSION = new WeakMap<PlaitBoard, number>();
|
||||
const VIEWPORT_RESTORE_INTERACTION_CLEANUP = new WeakMap<
|
||||
PlaitBoard,
|
||||
() => void
|
||||
>();
|
||||
|
||||
const syncViewportContainer = (board: PlaitBoard, reason: string) => {
|
||||
initializeViewportContainer(board);
|
||||
initializeViewBox(board);
|
||||
updateViewportOffset(board);
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
stabilizeViewportOffset(board, reason);
|
||||
};
|
||||
|
||||
const isSameViewport = (a?: Viewport, b?: Viewport) => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.zoom === b.zoom &&
|
||||
a.offsetX === b.offsetX &&
|
||||
a.offsetY === b.offsetY &&
|
||||
a.origination?.[0] === b.origination?.[0] &&
|
||||
a.origination?.[1] === b.origination?.[1]
|
||||
);
|
||||
};
|
||||
|
||||
const VIEWPORT_DEBUG_KEY = 'aitu_debug_viewport';
|
||||
|
||||
const isViewportDebugEnabled = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return (
|
||||
params.get('debugViewport') === '1' ||
|
||||
window.localStorage.getItem(VIEWPORT_DEBUG_KEY) === 'true'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const summarizeViewport = (viewport?: Viewport) => ({
|
||||
zoom: viewport?.zoom,
|
||||
originX: viewport?.origination?.[0],
|
||||
originY: viewport?.origination?.[1],
|
||||
offsetX: viewport?.offsetX,
|
||||
offsetY: viewport?.offsetY,
|
||||
});
|
||||
|
||||
const getViewportDomSnapshot = (board: PlaitBoard) => {
|
||||
try {
|
||||
const viewportContainer = PlaitBoard.getViewportContainer(board);
|
||||
const host = PlaitBoard.getHost(board);
|
||||
return {
|
||||
domScrollLeft: viewportContainer.scrollLeft,
|
||||
domScrollTop: viewportContainer.scrollTop,
|
||||
domScrollWidth: viewportContainer.scrollWidth,
|
||||
domScrollHeight: viewportContainer.scrollHeight,
|
||||
domClientWidth: viewportContainer.clientWidth,
|
||||
domClientHeight: viewportContainer.clientHeight,
|
||||
domOffsetWidth: viewportContainer.offsetWidth,
|
||||
domOffsetHeight: viewportContainer.offsetHeight,
|
||||
styleWidth: viewportContainer.style.width,
|
||||
styleHeight: viewportContainer.style.height,
|
||||
viewBox: host.getAttribute('viewBox'),
|
||||
svgWidth: host.style.width,
|
||||
svgHeight: host.style.height,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getExpectedViewportScroll = (board: PlaitBoard) => {
|
||||
try {
|
||||
const origin = board.viewport?.origination;
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
const viewBox = PlaitBoard.getHost(board).getAttribute('viewBox');
|
||||
if (!viewBox) {
|
||||
return null;
|
||||
}
|
||||
const [viewBoxX, viewBoxY] = viewBox
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => Number(value));
|
||||
if (!Number.isFinite(viewBoxX) || !Number.isFinite(viewBoxY)) {
|
||||
return null;
|
||||
}
|
||||
const zoom = board.viewport.zoom;
|
||||
return {
|
||||
targetScrollLeft: (origin[0] - viewBoxX) * zoom,
|
||||
targetScrollTop: (origin[1] - viewBoxY) * zoom,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const stabilizeViewportOffset = (board: PlaitBoard, reason: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const version = (VIEWPORT_RESTORE_VERSION.get(board) ?? 0) + 1;
|
||||
VIEWPORT_RESTORE_VERSION.set(board, version);
|
||||
attachViewportRestoreInteractionCancel(board, reason);
|
||||
let frame = 0;
|
||||
const run = () => {
|
||||
if (VIEWPORT_RESTORE_VERSION.get(board) !== version) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = getExpectedViewportScroll(board);
|
||||
if (!target) {
|
||||
logViewportDebug('wrapper:restore-scroll', board, {
|
||||
reason,
|
||||
status: 'no-target',
|
||||
attempts: frame + 1,
|
||||
});
|
||||
clearViewportRestoreInteractionCancel(board);
|
||||
setIsFromViewportChange(board, false);
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportContainer = PlaitBoard.getViewportContainer(board);
|
||||
const deltaLeft = Math.abs(
|
||||
viewportContainer.scrollLeft - target.targetScrollLeft
|
||||
);
|
||||
const deltaTop = Math.abs(
|
||||
viewportContainer.scrollTop - target.targetScrollTop
|
||||
);
|
||||
const needsRestore =
|
||||
deltaLeft > VIEWPORT_RESTORE_TOLERANCE ||
|
||||
deltaTop > VIEWPORT_RESTORE_TOLERANCE;
|
||||
|
||||
if (needsRestore) {
|
||||
restoreViewportContainerScroll(board, target);
|
||||
}
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
|
||||
const afterDeltaLeft = Math.abs(
|
||||
viewportContainer.scrollLeft - target.targetScrollLeft
|
||||
);
|
||||
const afterDeltaTop = Math.abs(
|
||||
viewportContainer.scrollTop - target.targetScrollTop
|
||||
);
|
||||
const isStable =
|
||||
afterDeltaLeft <= VIEWPORT_RESTORE_TOLERANCE &&
|
||||
afterDeltaTop <= VIEWPORT_RESTORE_TOLERANCE;
|
||||
const shouldContinue =
|
||||
frame + 1 < VIEWPORT_RESTORE_FRAMES &&
|
||||
(!isStable || frame + 1 < VIEWPORT_RESTORE_MIN_FRAMES);
|
||||
|
||||
if (!shouldContinue) {
|
||||
logViewportDebug('wrapper:restore-scroll', board, {
|
||||
reason,
|
||||
status: isStable ? 'ok' : 'mismatch',
|
||||
attempts: frame + 1,
|
||||
...target,
|
||||
deltaLeft: afterDeltaLeft,
|
||||
deltaTop: afterDeltaTop,
|
||||
});
|
||||
clearViewportRestoreInteractionCancel(board);
|
||||
setIsFromViewportChange(board, false);
|
||||
return;
|
||||
}
|
||||
|
||||
frame += 1;
|
||||
scheduleViewportRestoreFrame(run);
|
||||
};
|
||||
|
||||
scheduleViewportRestoreFrame(run);
|
||||
};
|
||||
|
||||
const scheduleViewportRestoreFrame = (callback: () => void) => {
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(callback);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(callback, 0);
|
||||
};
|
||||
|
||||
const restoreViewportContainerScroll = (
|
||||
board: PlaitBoard,
|
||||
target: { targetScrollLeft: number; targetScrollTop: number }
|
||||
) => {
|
||||
const viewportContainer = PlaitBoard.getViewportContainer(board);
|
||||
setIsFromViewportChange(board, true);
|
||||
viewportContainer.scrollLeft = target.targetScrollLeft;
|
||||
viewportContainer.scrollTop = target.targetScrollTop;
|
||||
|
||||
const deltaLeft = Math.abs(
|
||||
viewportContainer.scrollLeft - target.targetScrollLeft
|
||||
);
|
||||
const deltaTop = Math.abs(
|
||||
viewportContainer.scrollTop - target.targetScrollTop
|
||||
);
|
||||
|
||||
if (
|
||||
deltaLeft > VIEWPORT_RESTORE_TOLERANCE ||
|
||||
deltaTop > VIEWPORT_RESTORE_TOLERANCE
|
||||
) {
|
||||
updateViewportOffset(board);
|
||||
}
|
||||
refreshSelectedElementActiveSectionsForViewportChange(board);
|
||||
};
|
||||
|
||||
const attachViewportRestoreInteractionCancel = (
|
||||
board: PlaitBoard,
|
||||
reason: string
|
||||
) => {
|
||||
clearViewportRestoreInteractionCancel(board);
|
||||
|
||||
const cancel = (event: Event) => {
|
||||
cancelViewportRestore(board, reason, event);
|
||||
};
|
||||
const options = { capture: true, passive: true };
|
||||
|
||||
window.addEventListener('wheel', cancel, options);
|
||||
window.addEventListener('pointerdown', cancel, options);
|
||||
window.addEventListener('touchstart', cancel, options);
|
||||
window.addEventListener('keydown', cancel, options);
|
||||
|
||||
VIEWPORT_RESTORE_INTERACTION_CLEANUP.set(board, () => {
|
||||
window.removeEventListener('wheel', cancel, options);
|
||||
window.removeEventListener('pointerdown', cancel, options);
|
||||
window.removeEventListener('touchstart', cancel, options);
|
||||
window.removeEventListener('keydown', cancel, options);
|
||||
});
|
||||
};
|
||||
|
||||
const cancelViewportRestore = (
|
||||
board: PlaitBoard,
|
||||
reason: string,
|
||||
event?: Event
|
||||
) => {
|
||||
VIEWPORT_RESTORE_VERSION.set(
|
||||
board,
|
||||
(VIEWPORT_RESTORE_VERSION.get(board) ?? 0) + 1
|
||||
);
|
||||
clearViewportRestoreInteractionCancel(board);
|
||||
if (isZoomWheelEvent(event)) {
|
||||
ignoreUpcomingViewportScroll(board, 2);
|
||||
}
|
||||
setIsFromViewportChange(board, false);
|
||||
logViewportDebug('wrapper:restore-scroll', board, {
|
||||
reason,
|
||||
status: 'cancelled-by-user',
|
||||
});
|
||||
};
|
||||
|
||||
const isZoomWheelEvent = (event?: Event): event is WheelEvent => {
|
||||
return (
|
||||
typeof WheelEvent !== 'undefined' &&
|
||||
event instanceof WheelEvent &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
);
|
||||
};
|
||||
|
||||
const clearViewportRestoreInteractionCancel = (board: PlaitBoard) => {
|
||||
const cleanup = VIEWPORT_RESTORE_INTERACTION_CLEANUP.get(board);
|
||||
if (!cleanup) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
VIEWPORT_RESTORE_INTERACTION_CLEANUP.delete(board);
|
||||
};
|
||||
|
||||
const logViewportDebug = (
|
||||
stage: string,
|
||||
board: PlaitBoard,
|
||||
extra?: Record<string, unknown>
|
||||
) => {
|
||||
if (!isViewportDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDebugPayload = (payload: Record<string, unknown>): string => {
|
||||
return Object.entries(payload)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
return `${key}=${Number.isInteger(value) ? value : value.toFixed(3)}`;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'boolean') {
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
return `${key}=${JSON.stringify(value)}`;
|
||||
})
|
||||
.join(' ');
|
||||
};
|
||||
24
packages/react-board/tsconfig.json
Normal file
24
packages/react-board/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
23
packages/react-board/tsconfig.lib.json
Normal file
23
packages/react-board/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx"
|
||||
],
|
||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
19
packages/react-board/tsconfig.spec.json
Normal file
19
packages/react-board/tsconfig.spec.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
63
packages/react-board/vite.config.ts
Normal file
63
packages/react-board/vite.config.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/react-board',
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
nxViteTsPaths(),
|
||||
dts({
|
||||
entryRoot: 'src',
|
||||
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
|
||||
}),
|
||||
],
|
||||
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
outDir: '../../dist/react-board',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points.
|
||||
entry: 'src/index.ts',
|
||||
name: 'react-board',
|
||||
fileName: 'index',
|
||||
// Change this to the formats you want to support.
|
||||
// Don't forget to update your package.json as well.
|
||||
formats: ['es', 'cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime', '@plait/common', '@plait/core', '@plait/draw', '@plait/layouts', '@plait/mind', '@plait/text-plugins', 'classnames', '@plait-board/react-text', 'roughjs/bin/rough', 'slate', 'slate-react'],
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
globals: true,
|
||||
cache: {
|
||||
dir: '../../node_modules/.vitest',
|
||||
},
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/packages/react-board',
|
||||
provider: 'v8',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user