Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View 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": {}
}
]
}

View 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/).

View 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"
}
}
}

View 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"
}
}
}
}

View 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();
});
});

View 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 '';
};

View 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;

View 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;
};

View 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]);
}

View 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;

View File

@@ -0,0 +1,4 @@
export * from './board';
export * from './plugins/board';
export * from './wrapper';
export * from './hooks/use-board';

View 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;
}

View 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;
};

View 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);
};

View 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();
}

View 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;
}
}

View 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);
};

View 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(' ');
};

View 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"
}

View 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"]
}

View 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"
]
}

View 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',
},
},
});