diff --git a/README.md b/README.md
index 8325e901..3f194d3d 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,7 @@ drawnix/
├── dist/ # 构建产物
├── packages/
│ └── drawnix/ # 白板应用
+│ └── freehand/ # 自由画笔插件
│ └── react-board/ # 白板 React 视图层
│ └── react-text/ # 文本渲染模块
├── package.json
diff --git a/README_en.md b/README_en.md
index bc719635..18f47f61 100644
--- a/README_en.md
+++ b/README_en.md
@@ -86,6 +86,7 @@ drawnix/
├── dist/ # Build artifacts
├── packages/
│ └── drawnix/ # Whiteboard application core
+│ └── freehand/ # Reusable freehand plugin
│ └── react-board/ # Whiteboard react view layer
│ └── react-text/ # Text rendering module
├── package.json
diff --git a/packages/drawnix/package.json b/packages/drawnix/package.json
index eb4cebcc..86a6cf2c 100644
--- a/packages/drawnix/package.json
+++ b/packages/drawnix/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.24",
+ "@plait-board/freehand": "^0.4.0",
"@plait-board/markdown-to-drawnix": "^0.0.8",
"@plait-board/mermaid-to-drawnix": "^0.0.7",
"laser-pen": "^1.0.1",
diff --git a/packages/drawnix/src/components/toolbar/creation-toolbar.tsx b/packages/drawnix/src/components/toolbar/creation-toolbar.tsx
index 19925868..f8b218a7 100644
--- a/packages/drawnix/src/components/toolbar/creation-toolbar.tsx
+++ b/packages/drawnix/src/components/toolbar/creation-toolbar.tsx
@@ -28,7 +28,7 @@ import { ShapePicker, SHAPES } from '../shape-picker';
import { ArrowPicker, ARROWS } from '../arrow-picker';
import { useEffect, useState } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '../popover/popover';
-import { FreehandShape } from '../../plugins/freehand/type';
+import { FreehandShape } from '@plait-board/freehand';
import {
DrawnixFreehandPointer,
DrawnixPointerType,
diff --git a/packages/drawnix/src/components/toolbar/freehand-panel/freehand-panel.tsx b/packages/drawnix/src/components/toolbar/freehand-panel/freehand-panel.tsx
index 508c682f..db604654 100644
--- a/packages/drawnix/src/components/toolbar/freehand-panel/freehand-panel.tsx
+++ b/packages/drawnix/src/components/toolbar/freehand-panel/freehand-panel.tsx
@@ -8,14 +8,15 @@ import { BoardCreationMode, setCreationMode } from '@plait/common';
import { useBoard } from '@plait-board/react-board';
import { DrawnixPointerType } from '../../../hooks/use-drawnix';
import { useI18n } from '../../../i18n';
-import { FreehandShape } from '../../../plugins/freehand/type';
-import { FREEHANDS, FREEHAND_PRESET_IDS, type FreehandPresetId } from '../../../constants/freehand';
-import { FreehandStylePresetItem, type FreehandStylePreset } from './freehand-style-preset-item';
-import './freehand-style-preset-item.scss';
+import { FreehandShape, type FreehandDrawOptions } from '@plait-board/freehand';
import {
DEFAULT_FREEHAND_PRESETS,
- type FreehandDrawOptions,
-} from '../../../plugins/freehand/presets';
+ FREEHANDS,
+ FREEHAND_PRESET_IDS,
+ type FreehandPresetId,
+} from '../../../constants/freehand';
+import { FreehandStylePresetItem, type FreehandStylePreset } from './freehand-style-preset-item';
+import './freehand-style-preset-item.scss';
const FreehandStyleDivider = () => ;
diff --git a/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.spec.tsx b/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.spec.tsx
index 94c2097f..042302e5 100644
--- a/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.spec.tsx
+++ b/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.spec.tsx
@@ -21,11 +21,8 @@ vi.mock('@plait-board/react-board', () => ({
}),
}));
-vi.mock('../../../plugins/freehand/utils', () => ({
+vi.mock('@plait-board/freehand', () => ({
getFreehandDefaultStrokeColor: () => '#000000',
-}));
-
-vi.mock('../../../plugins/freehand/type', () => ({
FREEHAND_STROKE_WIDTH_STEP: 0.25,
MAX_FREEHAND_STROKE_WIDTH: 24,
MIN_FREEHAND_STROKE_WIDTH: 1,
diff --git a/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.tsx b/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.tsx
index 80ce7932..3b0438e9 100644
--- a/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.tsx
+++ b/packages/drawnix/src/components/toolbar/freehand-panel/freehand-style-preset-item.tsx
@@ -8,12 +8,12 @@ import { SizeSlider } from '../../size-slider';
import { useI18n } from '../../../i18n';
import { ColorPicker } from '../../color-picker';
import { useBoard } from '@plait-board/react-board';
-import { getFreehandDefaultStrokeColor } from '../../../plugins/freehand/utils';
+import { getFreehandDefaultStrokeColor } from '@plait-board/freehand';
import {
FREEHAND_STROKE_WIDTH_STEP,
MAX_FREEHAND_STROKE_WIDTH,
MIN_FREEHAND_STROKE_WIDTH,
-} from '../../../plugins/freehand/type';
+} from '@plait-board/freehand';
import { isNoColor, isWhite } from '../../../utils/color';
const formatSize = (value: number) => {
diff --git a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx
index 2aa1e5bd..1ef66f1b 100644
--- a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx
+++ b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx
@@ -38,7 +38,7 @@ import { PopupStrokeButton } from './stroke-button';
import { PopupFillButton } from './fill-button';
import { isWhite, removeHexAlpha } from '../../../utils/color';
import { NO_COLOR } from '../../../constants/color';
-import { Freehand } from '../../../plugins/freehand/type';
+import { Freehand } from '@plait-board/freehand';
import { PopupLinkButton } from './link-button';
import { ArrowMarkButton } from './arrow-mark-button';
import { MoreOptionsButton } from './more-options-button';
diff --git a/packages/drawnix/src/constants/freehand.ts b/packages/drawnix/src/constants/freehand.ts
index 9cbe5aca..c0b0db00 100644
--- a/packages/drawnix/src/constants/freehand.ts
+++ b/packages/drawnix/src/constants/freehand.ts
@@ -1,12 +1,35 @@
import type React from 'react';
import { EraseIcon, FeltTipPenIcon } from '../components/icons';
-import { FreehandShape } from '../plugins/freehand/type';
+import {
+ DEFAULT_FREEHAND_STROKE_WIDTH,
+ FreehandShape,
+ type FreehandDrawOptions,
+} from '@plait-board/freehand';
import type { Translations } from '../i18n';
+import { CLASSIC_COLORS } from './color';
export const FREEHAND_PRESET_IDS = ['preset-1', 'preset-2', 'preset-3'] as const;
export type FreehandPresetId = (typeof FREEHAND_PRESET_IDS)[number];
+const getClassicColorValue = (name: string) => {
+ return CLASSIC_COLORS.find((item) => item.name === name)?.value;
+};
+
+export const DEFAULT_FREEHAND_PRESETS: FreehandDrawOptions[] = [
+ {
+ strokeWidth: DEFAULT_FREEHAND_STROKE_WIDTH,
+ },
+ {
+ strokeColor: getClassicColorValue('color.red') || CLASSIC_COLORS[5].value,
+ strokeWidth: 6,
+ },
+ {
+ strokeColor: getClassicColorValue('color.green') || CLASSIC_COLORS[6].value,
+ strokeWidth: 10,
+ },
+];
+
export type FreehandToolItem = {
titleKey: keyof Translations;
icon: React.ReactNode;
diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx
index 3dad96fc..780c58cd 100644
--- a/packages/drawnix/src/drawnix.tsx
+++ b/packages/drawnix/src/drawnix.tsx
@@ -26,7 +26,7 @@ import classNames from 'classnames';
import './styles/index.scss';
import { buildDrawnixHotkeyPlugin } from './plugins/with-hotkey';
import { buildToolStateSyncPlugin } from './plugins/with-tool-state-sync';
-import { withFreehand } from './plugins/freehand/with-freehand';
+import { withDrawnixFreehand } from './plugins/with-freehand';
import { ThemeToolbar } from './components/toolbar/theme-toolbar';
import { buildPencilPlugin } from './plugins/with-pencil';
import {
@@ -204,7 +204,7 @@ export const Drawnix: React.FC = ({
withMindExtend,
withCommonPlugin,
buildDrawnixHotkeyPlugin(updateAppState),
- withFreehand,
+ withDrawnixFreehand,
buildPencilPlugin(updateAppState),
buildTextLinkPlugin(updateAppState),
buildToolStateSyncPlugin(syncBoardPointerToToolState),
diff --git a/packages/drawnix/src/hooks/use-drawnix.spec.ts b/packages/drawnix/src/hooks/use-drawnix.spec.ts
index 635b8383..ee6decd7 100644
--- a/packages/drawnix/src/hooks/use-drawnix.spec.ts
+++ b/packages/drawnix/src/hooks/use-drawnix.spec.ts
@@ -1,7 +1,7 @@
import { PlaitPointerType } from '@plait/core';
import { ArrowLineShape, BasicShapes } from '@plait/draw';
import { describe, expect, it, vi } from 'vitest';
-import { FreehandShape } from '../plugins/freehand/type';
+import { FreehandShape } from '@plait-board/freehand';
import { createDefaultToolState, mergeToolState } from './use-drawnix';
vi.mock('@plait/core', () => ({
diff --git a/packages/drawnix/src/hooks/use-drawnix.tsx b/packages/drawnix/src/hooks/use-drawnix.tsx
index 2164d7ce..7889fc07 100644
--- a/packages/drawnix/src/hooks/use-drawnix.tsx
+++ b/packages/drawnix/src/hooks/use-drawnix.tsx
@@ -6,10 +6,10 @@ import { PlaitBoard, PlaitPointerType } from '@plait/core';
import { createContext, useContext, type Dispatch, type SetStateAction } from 'react';
import { MindPointerType } from '@plait/mind';
import { ArrowLineShape, BasicShapes, DrawPointerType } from '@plait/draw';
-import { FreehandShape } from '../plugins/freehand/type';
+import { FreehandShape, type FreehandDrawOptions } from '@plait-board/freehand';
import { Editor } from 'slate';
import { LinkElement } from '@plait/common';
-import { DEFAULT_FREEHAND_PRESETS, FreehandDrawOptions } from '../plugins/freehand/presets';
+import { DEFAULT_FREEHAND_PRESETS } from '../constants/freehand';
import { DrawnixFileHandle } from '../data/json';
import type { DrawnixToastOptions } from '../components/toast/toast';
diff --git a/packages/drawnix/src/plugins/with-freehand.ts b/packages/drawnix/src/plugins/with-freehand.ts
new file mode 100644
index 00000000..a42dbed2
--- /dev/null
+++ b/packages/drawnix/src/plugins/with-freehand.ts
@@ -0,0 +1,27 @@
+import {
+ createFreehandPlugin,
+ resolveFreehandDrawOptions,
+ type FreehandDrawOptions,
+} from '@plait-board/freehand';
+import { isTwoFingerMode } from '@plait-board/react-board';
+import type { PlaitBoard } from '@plait/core';
+import { DEFAULT_FREEHAND_PRESETS } from '../constants/freehand';
+import type { DrawnixBoard } from '../hooks/use-drawnix';
+import { LaserPointer } from '../utils/laser-pointer';
+
+const getDrawOptions = (board: PlaitBoard): Partial => {
+ const toolState = (board as DrawnixBoard).appState?.toolState;
+ const activePresetIndex = toolState?.activeFreehandPresetIndex || 0;
+ const activePreset =
+ toolState?.freehandPresets?.[activePresetIndex] ||
+ DEFAULT_FREEHAND_PRESETS[activePresetIndex] ||
+ DEFAULT_FREEHAND_PRESETS[0];
+
+ return resolveFreehandDrawOptions(activePreset);
+};
+
+export const withDrawnixFreehand = createFreehandPlugin({
+ createEraseTrail: () => new LaserPointer(),
+ getDrawOptions,
+ isInteractionBlocked: isTwoFingerMode,
+});
diff --git a/packages/drawnix/src/plugins/with-hotkey.ts b/packages/drawnix/src/plugins/with-hotkey.ts
index 28c714eb..cb432dbc 100644
--- a/packages/drawnix/src/plugins/with-hotkey.ts
+++ b/packages/drawnix/src/plugins/with-hotkey.ts
@@ -11,7 +11,7 @@ import {
} from '../hooks/use-drawnix';
import { BoardCreationMode, setCreationMode } from '@plait/common';
import { MindPointerType } from '@plait/mind';
-import { FreehandShape } from './freehand/type';
+import { FreehandShape } from '@plait-board/freehand';
import { ArrowLineShape, BasicShapes } from '@plait/draw';
export const buildDrawnixHotkeyPlugin = (
diff --git a/packages/drawnix/src/plugins/with-tool-state-sync.spec.ts b/packages/drawnix/src/plugins/with-tool-state-sync.spec.ts
index 7e18ea1e..79abbdfb 100644
--- a/packages/drawnix/src/plugins/with-tool-state-sync.spec.ts
+++ b/packages/drawnix/src/plugins/with-tool-state-sync.spec.ts
@@ -26,7 +26,7 @@ vi.mock('@plait/mind', () => ({
},
}));
-vi.mock('../plugins/freehand/type', () => ({
+vi.mock('@plait-board/freehand', () => ({
FreehandShape: {
feltTipPen: 'feltTipPen',
eraser: 'eraser',
diff --git a/packages/drawnix/vite.config.ts b/packages/drawnix/vite.config.ts
index d08dcb43..07905d44 100644
--- a/packages/drawnix/vite.config.ts
+++ b/packages/drawnix/vite.config.ts
@@ -49,6 +49,7 @@ export default defineConfig({
'react-dom/client',
'react/jsx-runtime',
'@plait-board/react-board',
+ '@plait-board/freehand',
'@plait-board/mermaid-to-drawnix',
'@plait-board/markdown-to-drawnix',
'classnames',
diff --git a/packages/freehand/README.md b/packages/freehand/README.md
new file mode 100644
index 00000000..82dfb094
--- /dev/null
+++ b/packages/freehand/README.md
@@ -0,0 +1,26 @@
+# @plait-board/freehand
+
+Reusable freehand drawing and erasing plugins for Plait boards.
+
+```ts
+import { createFreehandPlugin } from '@plait-board/freehand';
+import { withOptions } from '@plait/core';
+import { withDraw } from '@plait/draw';
+
+const withFreehand = createFreehandPlugin({
+ getDrawOptions: () => ({ strokeWidth: 2 }),
+ isInteractionBlocked: (board) => isPinching(board),
+ createEraseTrail: () => new MyEraseTrail(),
+});
+
+const plugins = [withOptions, withDraw, withFreehand];
+```
+
+Application-specific toolbars, persisted presets, gesture policy, and eraser-trail presentation can be supplied through `createFreehandPlugin` options.
+
+## Development
+
+```sh
+npx nx test freehand
+npx nx build freehand
+```
diff --git a/packages/freehand/package.json b/packages/freehand/package.json
new file mode 100644
index 00000000..f5dfb7cd
--- /dev/null
+++ b/packages/freehand/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@plait-board/freehand",
+ "version": "0.4.0",
+ "private": false,
+ "main": "./index.js",
+ "types": "./index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./index.mjs",
+ "require": "./index.js",
+ "types": "./index.d.ts"
+ }
+ },
+ "dependencies": {
+ "@plait/common": "^0.93.4",
+ "@plait/core": "^0.93.4",
+ "@plait/draw": "^0.93.4",
+ "@plait/text-plugins": "^0.93.4",
+ "roughjs": "^4.6.6"
+ },
+ "peerDependencies": {
+ "slate": "^0.116.0"
+ }
+}
diff --git a/packages/freehand/project.json b/packages/freehand/project.json
new file mode 100644
index 00000000..da47766a
--- /dev/null
+++ b/packages/freehand/project.json
@@ -0,0 +1,8 @@
+{
+ "name": "freehand",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "packages/freehand/src",
+ "projectType": "library",
+ "tags": [],
+ "targets": {}
+}
diff --git a/packages/drawnix/src/plugins/freehand/freehand.component.spec.ts b/packages/freehand/src/freehand.component.spec.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/freehand.component.spec.ts
rename to packages/freehand/src/freehand.component.spec.ts
diff --git a/packages/drawnix/src/plugins/freehand/freehand.component.ts b/packages/freehand/src/freehand.component.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/freehand.component.ts
rename to packages/freehand/src/freehand.component.ts
diff --git a/packages/drawnix/src/plugins/freehand/freehand.generator.ts b/packages/freehand/src/freehand.generator.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/freehand.generator.ts
rename to packages/freehand/src/freehand.generator.ts
diff --git a/packages/freehand/src/index.ts b/packages/freehand/src/index.ts
new file mode 100644
index 00000000..05f918fb
--- /dev/null
+++ b/packages/freehand/src/index.ts
@@ -0,0 +1,9 @@
+export type {
+ FreehandEraseTrail,
+ FreehandPluginOptions,
+ FreehandThemeColor,
+} from './plugin-options';
+export * from './presets';
+export * from './type';
+export * from './utils';
+export * from './with-freehand';
diff --git a/packages/freehand/src/plugin-options.spec.ts b/packages/freehand/src/plugin-options.spec.ts
new file mode 100644
index 00000000..81fd37e4
--- /dev/null
+++ b/packages/freehand/src/plugin-options.spec.ts
@@ -0,0 +1,23 @@
+import { ThemeColorMode, type PlaitBoard } from '@plait/core';
+import { describe, expect, it } from 'vitest';
+import { getFreehandPluginOptions, setFreehandPluginOptions } from './plugin-options';
+
+describe('freehand plugin options', () => {
+ it('merges a theme override without dropping package defaults', () => {
+ const board = {} as PlaitBoard;
+
+ setFreehandPluginOptions(board, {
+ themeColors: {
+ [ThemeColorMode.default]: {
+ strokeColor: '#123456',
+ fill: 'none',
+ },
+ },
+ });
+
+ const options = getFreehandPluginOptions(board);
+
+ expect(options.themeColors[ThemeColorMode.default].strokeColor).toBe('#123456');
+ expect(options.themeColors[ThemeColorMode.dark].strokeColor).toBe('#FFFFFF');
+ });
+});
diff --git a/packages/freehand/src/plugin-options.ts b/packages/freehand/src/plugin-options.ts
new file mode 100644
index 00000000..cc9a9ab3
--- /dev/null
+++ b/packages/freehand/src/plugin-options.ts
@@ -0,0 +1,64 @@
+import type { PlaitBoard, ThemeColorMode } from '@plait/core';
+import { DEFAULT_FREEHAND_DRAW_OPTIONS, type FreehandDrawOptions } from './presets';
+import { FreehandThemeColors } from './type';
+
+export interface FreehandEraseTrail {
+ init(board: PlaitBoard): void;
+ destroy(): void;
+}
+
+export type FreehandThemeColor = {
+ strokeColor: string;
+ fill: string;
+};
+
+export type FreehandPluginOptions = {
+ createEraseTrail?: () => FreehandEraseTrail;
+ getDrawOptions?: (board: PlaitBoard) => Partial;
+ isInteractionBlocked?: (board: PlaitBoard) => boolean;
+ themeColors?: Partial>;
+};
+
+type ResolvedFreehandPluginOptions = {
+ createEraseTrail: () => FreehandEraseTrail;
+ getDrawOptions: (board: PlaitBoard) => Partial;
+ isInteractionBlocked: (board: PlaitBoard) => boolean;
+ themeColors: Record;
+};
+
+const FREEHAND_PLUGIN_OPTIONS = new WeakMap();
+
+const createNoopEraseTrail = (): FreehandEraseTrail => ({
+ init: () => {},
+ destroy: () => {},
+});
+
+const resolveFreehandPluginOptions = (
+ options: FreehandPluginOptions = {}
+): ResolvedFreehandPluginOptions => ({
+ createEraseTrail: options.createEraseTrail || createNoopEraseTrail,
+ getDrawOptions: options.getDrawOptions || (() => DEFAULT_FREEHAND_DRAW_OPTIONS),
+ isInteractionBlocked: options.isInteractionBlocked || (() => false),
+ themeColors: {
+ ...FreehandThemeColors,
+ ...options.themeColors,
+ },
+});
+
+export const setFreehandPluginOptions = (
+ board: PlaitBoard,
+ options: FreehandPluginOptions = {}
+) => {
+ FREEHAND_PLUGIN_OPTIONS.set(board, resolveFreehandPluginOptions(options));
+};
+
+export const getFreehandPluginOptions = (board: PlaitBoard) => {
+ const options = FREEHAND_PLUGIN_OPTIONS.get(board);
+ if (options) {
+ return options;
+ }
+
+ const defaultOptions = resolveFreehandPluginOptions();
+ FREEHAND_PLUGIN_OPTIONS.set(board, defaultOptions);
+ return defaultOptions;
+};
diff --git a/packages/drawnix/src/plugins/freehand/pointer-button.spec.ts b/packages/freehand/src/pointer-button.spec.ts
similarity index 61%
rename from packages/drawnix/src/plugins/freehand/pointer-button.spec.ts
rename to packages/freehand/src/pointer-button.spec.ts
index ceccb552..372ea3db 100644
--- a/packages/drawnix/src/plugins/freehand/pointer-button.spec.ts
+++ b/packages/freehand/src/pointer-button.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
insertNode: vi.fn(),
@@ -9,15 +9,12 @@ vi.mock('@plait/common', () => ({
isDrawingMode: () => true,
}));
-vi.mock('@plait-board/react-board', () => ({
- isTwoFingerMode: () => false,
-}));
-
vi.mock('@plait/core', () => ({
CoreTransforms: {
removeElements: mocks.removeElements,
},
DEFAULT_COLOR: '#000000',
+ idCreator: () => 'freehand-id',
PlaitBoard: {
getElementTopHost: () => ({}),
getPointer: (board: { pointer: string }) => board.pointer,
@@ -62,16 +59,10 @@ vi.mock('./smoother', () => ({
},
}));
-vi.mock('../../utils/laser-pointer', () => ({
- LaserPointer: class {
- destroy = vi.fn();
- init = vi.fn();
- },
-}));
-
import { withFreehandCreate } from './with-freehand-create';
import { withFreehandErase } from './with-freehand-erase';
import { FreehandShape } from './type';
+import { setFreehandPluginOptions } from './plugin-options';
const createPointerEvent = (button: number) =>
({
@@ -94,6 +85,10 @@ const createBoard = (pointer: string) => ({
});
describe('freehand pointer buttons', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
it('does not start freehand drawing from the middle mouse button', () => {
const board = createBoard(FreehandShape.feltTipPen);
const originalPointerDown = board.pointerDown;
@@ -121,4 +116,60 @@ describe('freehand pointer buttons', () => {
expect(originalPointerDown).toHaveBeenCalledOnce();
expect(mocks.removeElements).not.toHaveBeenCalled();
});
+
+ it('uses application-provided draw options without reading application state', () => {
+ const board = createBoard(FreehandShape.feltTipPen);
+ setFreehandPluginOptions(board as any, {
+ getDrawOptions: () => ({
+ strokeColor: '#FF4500',
+ strokeWidth: 6,
+ }),
+ });
+
+ withFreehandCreate(board as any);
+ board.pointerDown(createPointerEvent(0));
+ board.pointerUp(createPointerEvent(0));
+
+ expect(mocks.insertNode).toHaveBeenCalledWith(
+ board,
+ expect.objectContaining({
+ id: 'freehand-id',
+ strokeColor: '#FF4500',
+ strokeWidth: 6,
+ }),
+ [0]
+ );
+ });
+
+ it('uses an application-provided eraser trail lifecycle', () => {
+ const board = createBoard(FreehandShape.eraser);
+ const eraseTrail = {
+ init: vi.fn(),
+ destroy: vi.fn(),
+ };
+ setFreehandPluginOptions(board as any, {
+ createEraseTrail: () => eraseTrail,
+ });
+
+ withFreehandErase(board as any);
+ board.pointerDown(createPointerEvent(0));
+ board.pointerUp(createPointerEvent(0));
+
+ expect(eraseTrail.init).toHaveBeenCalledWith(board);
+ expect(eraseTrail.destroy).toHaveBeenCalledOnce();
+ });
+
+ it('cancels an active stroke when application interaction policy blocks it', () => {
+ const board = createBoard(FreehandShape.feltTipPen);
+ setFreehandPluginOptions(board as any, {
+ isInteractionBlocked: () => true,
+ });
+
+ withFreehandCreate(board as any);
+ board.pointerDown(createPointerEvent(0));
+ board.pointerMove(createPointerEvent(0));
+ board.pointerUp(createPointerEvent(0));
+
+ expect(mocks.insertNode).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/drawnix/src/plugins/freehand/presets.ts b/packages/freehand/src/presets.ts
similarity index 62%
rename from packages/drawnix/src/plugins/freehand/presets.ts
rename to packages/freehand/src/presets.ts
index 15150a59..602814cf 100644
--- a/packages/drawnix/src/plugins/freehand/presets.ts
+++ b/packages/freehand/src/presets.ts
@@ -1,5 +1,3 @@
-import { CLASSIC_COLORS } from '../../constants/color';
-
export const DEFAULT_FREEHAND_STROKE_WIDTH = 2;
export const MIN_FREEHAND_STROKE_WIDTH = 1;
@@ -15,24 +13,10 @@ export type FreehandDrawOptions = {
strokeWidth: number;
};
-const getClassicColorValue = (name: string) => {
- return CLASSIC_COLORS.find((item) => item.name === name)?.value;
+export const DEFAULT_FREEHAND_DRAW_OPTIONS: FreehandDrawOptions = {
+ strokeWidth: DEFAULT_FREEHAND_STROKE_WIDTH,
};
-export const DEFAULT_FREEHAND_PRESETS: FreehandDrawOptions[] = [
- {
- strokeWidth: DEFAULT_FREEHAND_STROKE_WIDTH,
- },
- {
- strokeColor: getClassicColorValue('color.red') || CLASSIC_COLORS[5].value,
- strokeWidth: 6,
- },
- {
- strokeColor: getClassicColorValue('color.green') || CLASSIC_COLORS[6].value,
- strokeWidth: 10,
- },
-];
-
export const resolveFreehandDrawOptions = (drawOptions: Partial = {}) => {
const normalizedDrawOptions = {} as Partial;
diff --git a/packages/drawnix/src/plugins/freehand/smoother.ts b/packages/freehand/src/smoother.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/smoother.ts
rename to packages/freehand/src/smoother.ts
diff --git a/packages/drawnix/src/plugins/freehand/type.ts b/packages/freehand/src/type.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/type.ts
rename to packages/freehand/src/type.ts
diff --git a/packages/drawnix/src/plugins/freehand/utils.spec.ts b/packages/freehand/src/utils.spec.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/utils.spec.ts
rename to packages/freehand/src/utils.spec.ts
diff --git a/packages/drawnix/src/plugins/freehand/utils.ts b/packages/freehand/src/utils.ts
similarity index 86%
rename from packages/drawnix/src/plugins/freehand/utils.ts
rename to packages/freehand/src/utils.ts
index 148c58c3..9bde35f2 100644
--- a/packages/drawnix/src/plugins/freehand/utils.ts
+++ b/packages/freehand/src/utils.ts
@@ -11,11 +11,8 @@ import {
ThemeColorMode,
} from '@plait/core';
import { Freehand, FreehandShape, FreehandThemeColors } from './type';
-import {
- DEFAULT_FREEHAND_PRESETS,
- type FreehandDrawOptions,
- resolveFreehandDrawOptions,
-} from './presets';
+import { type FreehandDrawOptions, resolveFreehandDrawOptions } from './presets';
+import { getFreehandPluginOptions } from './plugin-options';
import {
DefaultDrawStyle,
isClosedCustomGeometry,
@@ -24,26 +21,12 @@ import {
isRectangleHitRotatedPoints,
} from '@plait/draw';
-type FreehandAppState = {
- toolState?: {
- activeFreehandPresetIndex?: number;
- freehandPresets?: FreehandDrawOptions[];
- };
-};
-
export function getFreehandPointers() {
return [FreehandShape.feltTipPen, FreehandShape.eraser];
}
export const getFreehandDrawOptions = (board: PlaitBoard) => {
- const appState = (board as PlaitBoard & { appState?: FreehandAppState }).appState;
- const activePresetIndex = appState?.toolState?.activeFreehandPresetIndex || 0;
- const activePreset =
- appState?.toolState?.freehandPresets?.[activePresetIndex] ||
- DEFAULT_FREEHAND_PRESETS[activePresetIndex] ||
- DEFAULT_FREEHAND_PRESETS[0];
-
- return resolveFreehandDrawOptions(activePreset);
+ return resolveFreehandDrawOptions(getFreehandPluginOptions(board).getDrawOptions(board));
};
export const createFreehandElement = (
@@ -94,7 +77,8 @@ export const getFreehandDefaultFill = (theme: ThemeColorMode) => {
};
export const getStrokeColorByElement = (board: PlaitBoard, element: PlaitElement) => {
- const defaultColor = getFreehandDefaultStrokeColor(board.theme.themeColorMode);
+ const defaultColor =
+ getFreehandPluginOptions(board).themeColors[board.theme.themeColorMode].strokeColor;
const strokeColor = element.strokeColor || defaultColor;
return strokeColor;
};
@@ -102,7 +86,7 @@ export const getStrokeColorByElement = (board: PlaitBoard, element: PlaitElement
export const getFillByElement = (board: PlaitBoard, element: PlaitElement) => {
const defaultFill =
Freehand.isFreehand(element) && isClosedCustomGeometry(board, element)
- ? getFreehandDefaultFill(board.theme.themeColorMode)
+ ? getFreehandPluginOptions(board).themeColors[board.theme.themeColorMode].fill
: DefaultDrawStyle.fill;
const fill = element.fill || defaultFill;
return fill;
diff --git a/packages/drawnix/src/plugins/freehand/with-freehand-create.ts b/packages/freehand/src/with-freehand-create.ts
similarity index 93%
rename from packages/drawnix/src/plugins/freehand/with-freehand-create.ts
rename to packages/freehand/src/with-freehand-create.ts
index 99b3c063..723d981e 100644
--- a/packages/drawnix/src/plugins/freehand/with-freehand-create.ts
+++ b/packages/freehand/src/with-freehand-create.ts
@@ -12,7 +12,7 @@ import { createFreehandElement, getFreehandDrawOptions, getFreehandPointers } fr
import { Freehand, FreehandShape } from './type';
import { FreehandGenerator } from './freehand.generator';
import { FreehandSmoother } from './smoother';
-import { isTwoFingerMode } from '@plait-board/react-board';
+import { getFreehandPluginOptions } from './plugin-options';
export const withFreehandCreate = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp, globalPointerUp, touchStart } = board;
@@ -76,7 +76,8 @@ export const withFreehandCreate = (board: PlaitBoard) => {
};
board.pointerMove = (event: PointerEvent) => {
- if (isDrawing && !isTwoFingerMode(board)) {
+ const isInteractionBlocked = getFreehandPluginOptions(board).isInteractionBlocked(board);
+ if (isDrawing && !isInteractionBlocked) {
const currentScreenPoint: Point = [event.x, event.y];
if (
originScreenPoint &&
@@ -105,7 +106,7 @@ export const withFreehandCreate = (board: PlaitBoard) => {
}
return;
}
- if (isTwoFingerMode(board) && isDrawing) {
+ if (isInteractionBlocked && isDrawing) {
complete(true);
return;
}
diff --git a/packages/drawnix/src/plugins/freehand/with-freehand-erase.ts b/packages/freehand/src/with-freehand-erase.ts
similarity index 88%
rename from packages/drawnix/src/plugins/freehand/with-freehand-erase.ts
rename to packages/freehand/src/with-freehand-erase.ts
index c707e883..e0c98a46 100644
--- a/packages/drawnix/src/plugins/freehand/with-freehand-erase.ts
+++ b/packages/freehand/src/with-freehand-erase.ts
@@ -11,13 +11,12 @@ import {
import { isDrawingMode } from '@plait/common';
import { isHitFreehand } from './utils';
import { Freehand, FreehandShape } from './type';
-import { LaserPointer } from '../../utils/laser-pointer';
-import { isTwoFingerMode } from '@plait-board/react-board';
+import { getFreehandPluginOptions } from './plugin-options';
export const withFreehandErase = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp, globalPointerUp, touchStart } = board;
- const laserPointer = new LaserPointer();
+ const eraseTrail = getFreehandPluginOptions(board).createEraseTrail();
let isErasing = false;
const elementsToDelete = new Set();
@@ -52,7 +51,7 @@ export const withFreehandErase = (board: PlaitBoard) => {
deleteMarkedElements();
isErasing = false;
elementsToDelete.clear();
- laserPointer.destroy();
+ eraseTrail.destroy();
}
};
@@ -72,7 +71,7 @@ export const withFreehandErase = (board: PlaitBoard) => {
elementsToDelete.clear();
const currentPoint: Point = [event.x, event.y];
checkAndMarkFreehandElementsForDeletion(currentPoint);
- laserPointer.init(board);
+ eraseTrail.init(board);
return;
}
@@ -80,14 +79,15 @@ export const withFreehandErase = (board: PlaitBoard) => {
};
board.pointerMove = (event: PointerEvent) => {
- if (isErasing && !isTwoFingerMode(board)) {
+ const isInteractionBlocked = getFreehandPluginOptions(board).isInteractionBlocked(board);
+ if (isErasing && !isInteractionBlocked) {
throttleRAF(board, 'with-freehand-erase', () => {
const currentPoint: Point = [event.x, event.y];
checkAndMarkFreehandElementsForDeletion(currentPoint);
});
return;
}
- if (isErasing && isTwoFingerMode(board)) {
+ if (isErasing && isInteractionBlocked) {
complete();
return;
}
diff --git a/packages/drawnix/src/plugins/freehand/with-freehand-fragment.ts b/packages/freehand/src/with-freehand-fragment.ts
similarity index 100%
rename from packages/drawnix/src/plugins/freehand/with-freehand-fragment.ts
rename to packages/freehand/src/with-freehand-fragment.ts
diff --git a/packages/freehand/src/with-freehand.spec.ts b/packages/freehand/src/with-freehand.spec.ts
new file mode 100644
index 00000000..d1988c20
--- /dev/null
+++ b/packages/freehand/src/with-freehand.spec.ts
@@ -0,0 +1,28 @@
+import { createTestingBoard, withOptions } from '@plait/core';
+import { withDraw } from '@plait/draw';
+import { describe, expect, it } from 'vitest';
+import { getFreehandDrawOptions } from './utils';
+import { createFreehandPlugin } from './with-freehand';
+
+describe('createFreehandPlugin', () => {
+ it('composes with a real Plait board and applies consumer options', () => {
+ const board = createTestingBoard(
+ [
+ withOptions,
+ withDraw,
+ createFreehandPlugin({
+ getDrawOptions: () => ({
+ strokeColor: '#123456',
+ strokeWidth: 4,
+ }),
+ }),
+ ],
+ []
+ );
+
+ expect(getFreehandDrawOptions(board)).toEqual({
+ strokeColor: '#123456',
+ strokeWidth: 4,
+ });
+ });
+});
diff --git a/packages/drawnix/src/plugins/freehand/with-freehand.ts b/packages/freehand/src/with-freehand.ts
similarity index 83%
rename from packages/drawnix/src/plugins/freehand/with-freehand.ts
rename to packages/freehand/src/with-freehand.ts
index eabab4ab..4420d5e8 100644
--- a/packages/drawnix/src/plugins/freehand/with-freehand.ts
+++ b/packages/freehand/src/with-freehand.ts
@@ -5,6 +5,7 @@ import {
PlaitPluginElementContext,
RectangleClient,
Selection,
+ type PlaitPlugin,
} from '@plait/core';
import { Freehand, FREEHAND_TYPE } from './type';
import { FreehandComponent } from './freehand.component';
@@ -13,8 +14,10 @@ import { isHitFreehand, isRectangleHitFreehand } from './utils';
import { withFreehandFragment } from './with-freehand-fragment';
import { getHitDrawElement, WithDrawOptions, WithDrawPluginKey } from '@plait/draw';
import { withFreehandErase } from './with-freehand-erase';
+import { setFreehandPluginOptions, type FreehandPluginOptions } from './plugin-options';
-export const withFreehand = (board: PlaitBoard) => {
+const applyFreehandPlugin = (board: PlaitBoard, options: FreehandPluginOptions = {}) => {
+ setFreehandPluginOptions(board, options);
const { getRectangle, drawElement, isHit, isRectangleHit, getOneHitElement, isMovable, isAlign } =
board;
@@ -74,3 +77,9 @@ export const withFreehand = (board: PlaitBoard) => {
return withFreehandErase(withFreehandFragment(withFreehandCreate(board)));
};
+
+export const createFreehandPlugin = (options: FreehandPluginOptions = {}): PlaitPlugin => {
+ return (board) => applyFreehandPlugin(board, options);
+};
+
+export const withFreehand: PlaitPlugin = createFreehandPlugin();
diff --git a/packages/freehand/tsconfig.json b/packages/freehand/tsconfig.json
new file mode 100644
index 00000000..bab74ff2
--- /dev/null
+++ b/packages/freehand/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "allowJs": false,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "types": ["vite/client"]
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../tsconfig.base.json"
+}
diff --git a/packages/freehand/tsconfig.lib.json b/packages/freehand/tsconfig.lib.json
new file mode 100644
index 00000000..323b33a2
--- /dev/null
+++ b/packages/freehand/tsconfig.lib.json
@@ -0,0 +1,18 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "types": ["node", "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"]
+}
diff --git a/packages/freehand/tsconfig.spec.json b/packages/freehand/tsconfig.spec.json
new file mode 100644
index 00000000..d6709c6d
--- /dev/null
+++ b/packages/freehand/tsconfig.spec.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "commonjs",
+ "types": ["vitest/globals", "node"]
+ },
+ "include": [
+ "vite.config.ts",
+ "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"
+ ]
+}
diff --git a/packages/freehand/vite.config.ts b/packages/freehand/vite.config.ts
new file mode 100644
index 00000000..17551793
--- /dev/null
+++ b/packages/freehand/vite.config.ts
@@ -0,0 +1,43 @@
+///
+import { defineConfig } from 'vite';
+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/freehand',
+ plugins: [
+ nxViteTsPaths(),
+ dts({
+ entryRoot: 'src',
+ tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
+ }),
+ ],
+ build: {
+ outDir: '../../dist/freehand',
+ emptyOutDir: true,
+ reportCompressedSize: true,
+ commonjsOptions: {
+ transformMixedEsModules: true,
+ },
+ lib: {
+ entry: 'src/index.ts',
+ name: 'freehand',
+ fileName: 'index',
+ formats: ['es', 'cjs'],
+ },
+ rollupOptions: {
+ external: ['@plait/common', '@plait/core', '@plait/draw', 'roughjs/bin/core'],
+ },
+ },
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ coverage: {
+ reportsDirectory: '../../coverage/packages/freehand',
+ provider: 'v8',
+ },
+ },
+});
diff --git a/scripts/publish.js b/scripts/publish.js
index 00c575ed..bc38ab61 100644
--- a/scripts/publish.js
+++ b/scripts/publish.js
@@ -2,7 +2,7 @@ const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
-const libraries = ['react-board', 'react-text', 'drawnix'];
+const libraries = ['react-board', 'react-text', 'freehand', 'drawnix'];
libraries.forEach((lib) => {
const libPath = path.resolve(__dirname, '../dist', lib);
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 971681eb..4b70a147 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -17,6 +17,7 @@
"baseUrl": ".",
"paths": {
"@drawnix/drawnix": ["packages/drawnix/src/index.ts"],
+ "@plait-board/freehand": ["packages/freehand/src/index.ts"],
"@plait-board/react-board": ["packages/react-board/src/index.ts"],
"@plait-board/react-text": ["packages/react-text/src/index.ts"]
}