@@ -929,6 +989,7 @@ watch(getFloatingComments, () => {
@selection-change="handleSelectionChange"
@ready="handleDocumentReady"
@page-loaded="handlePageReady"
+ @page-ready="handleWhiteboardPageReady"
@bypass-selection="handlePdfClick"
/>
@@ -1074,6 +1135,7 @@ watch(getFloatingComments, () => {
background-color: rgba(219, 219, 219, 0.6);
border-radius: 12px;
cursor: pointer;
+ position: relative;
}
.tools-item i {
diff --git a/packages/superdoc/src/components/PdfViewer/PdfViewer.vue b/packages/superdoc/src/components/PdfViewer/PdfViewer.vue
index 4e364294c8..f1ad74c697 100644
--- a/packages/superdoc/src/components/PdfViewer/PdfViewer.vue
+++ b/packages/superdoc/src/components/PdfViewer/PdfViewer.vue
@@ -8,7 +8,7 @@ import { readFileAsArrayBuffer } from './helpers/read-file.js';
import useSelection from '@superdoc/helpers/use-selection';
import './pdf/pdf-viewer.css';
-const emit = defineEmits(['page-loaded', 'ready', 'selection-change', 'bypass-selection']);
+const emit = defineEmits(['page-loaded', 'page-ready', 'ready', 'selection-change', 'bypass-selection']);
const props = defineProps({
documentData: {
diff --git a/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js b/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js
index 1db925e0aa..24914de8d2 100644
--- a/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js
+++ b/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js
@@ -102,15 +102,12 @@ export class PDFJSAdapter extends PDFAdapter {
const firstPage = 1;
const pdfjsPages = await getPdfjsPages(pdfDocument, firstPage, numPages);
- const pageContainers = [];
-
for (const [index, page] of pdfjsPages.entries()) {
const container = document.createElement('div');
container.classList.add('pdf-page');
container.dataset.pageNumber = (index + 1).toString();
container.id = `${documentId}-page-${index + 1}`;
-
- pageContainers.push(container);
+ viewerContainer.append(container);
const { width, height } = this.getOriginalPageSize(page);
const scale = 1;
@@ -136,9 +133,16 @@ export class PDFJSAdapter extends PDFAdapter {
await pdfPageView.draw();
emit('page-loaded', documentId, index, containerBounds);
- }
- viewerContainer.append(...pageContainers);
+ emit('page-ready', {
+ documentId,
+ pageIndex: index,
+ width: containerBounds.width,
+ height: containerBounds.height,
+ originalWidth: width,
+ originalHeight: height,
+ });
+ }
emit('ready', documentId, viewerContainer);
} catch (err) {
diff --git a/packages/superdoc/src/components/Whiteboard/WhiteboardLayer.vue b/packages/superdoc/src/components/Whiteboard/WhiteboardLayer.vue
new file mode 100644
index 0000000000..ac512aa592
--- /dev/null
+++ b/packages/superdoc/src/components/Whiteboard/WhiteboardLayer.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/superdoc/src/components/Whiteboard/WhiteboardPage.vue b/packages/superdoc/src/components/Whiteboard/WhiteboardPage.vue
new file mode 100644
index 0000000000..d3ca4f2176
--- /dev/null
+++ b/packages/superdoc/src/components/Whiteboard/WhiteboardPage.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
diff --git a/packages/superdoc/src/components/Whiteboard/use-whiteboard.js b/packages/superdoc/src/components/Whiteboard/use-whiteboard.js
new file mode 100644
index 0000000000..8d19bc357a
--- /dev/null
+++ b/packages/superdoc/src/components/Whiteboard/use-whiteboard.js
@@ -0,0 +1,159 @@
+import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, markRaw } from 'vue';
+import { PDF } from '@superdoc/common';
+
+export const useWhiteboard = ({ proxy, layers, documents, modules }) => {
+ // Resolve module config (false disables whiteboard module).
+ const whiteboardModuleConfig = computed(() => {
+ const config = modules.value?.whiteboard ?? modules.whiteboard;
+ if (config === false || config == null) return null;
+ return config;
+ });
+
+ const whiteboard = proxy?.$superdoc?.whiteboard;
+
+ // NOTE: whiteboardPages holds class instances; keep them raw to avoid Vue proxying.
+ const whiteboardPages = shallowRef([]);
+ const whiteboardPageSizes = reactive({});
+ const whiteboardPageOffsets = reactive({});
+
+ const whiteboardOpacity = ref(1);
+ const whiteboardEnabled = ref(whiteboardModuleConfig.value?.enabled ?? false);
+
+ // Sync page size + instances when PDF viewer reports page-ready.
+ const handleWhiteboardPageReady = (payload) => {
+ if (!payload) return;
+ const doc = documents.value?.[0];
+ if (!doc) return;
+ if (doc.type === PDF) {
+ handleWhiteboardPDFPageReady(payload);
+ }
+ };
+
+ const handleWhiteboardPDFPageReady = (payload) => {
+ const { pageIndex, width, height, originalWidth, originalHeight } = payload;
+ const size = { width, height, originalWidth, originalHeight };
+ whiteboard?.setPageSize(pageIndex, size);
+ whiteboardPageSizes[pageIndex] = size;
+ const existingPage = whiteboard?.getPage(pageIndex);
+ if (existingPage) {
+ existingPage.setSize(size);
+ }
+ if (whiteboard) {
+ whiteboardPages.value = whiteboard
+ .getPages()
+ .sort((a, b) => a.pageIndex - b.pageIndex)
+ .map((page) => markRaw(page));
+ }
+ nextTick(() => updateWhiteboardPageOffsets());
+ };
+
+ // Re-emit for host app consumers.
+ const handleWhiteboardChange = (data) => {
+ proxy?.$superdoc?.emit('whiteboard:change', data);
+ console.debug('[Whiteboard] change', data);
+ };
+
+ // Update UI pages after setWhiteboardData.
+ const handleWhiteboardSetData = (pages) => {
+ pages.forEach((page) => {
+ const size = whiteboardPageSizes[page.pageIndex];
+ if (size) page.setSize(size);
+ });
+ whiteboardPages.value = pages.map((page) => markRaw(page));
+ nextTick(() => updateWhiteboardPageOffsets());
+ };
+
+ const handleWhiteboardEnabled = (enabled) => {
+ whiteboardEnabled.value = enabled;
+ };
+
+ const handleWhiteboardOpacity = (opacity) => {
+ whiteboardOpacity.value = opacity;
+ };
+
+ const handleWhiteboardTool = (tool) => {
+ proxy?.$superdoc?.emit('whiteboard:tool', tool);
+ };
+
+ // Recompute page sizes (used for PDF zoom changes).
+ const updateWhiteboardPageSizes = () => {
+ const doc = documents.value?.[0];
+ if (!doc) return;
+ if (doc.type === PDF) {
+ updateWhiteboardPDFPageSizes({ doc });
+ }
+ };
+
+ const updateWhiteboardPDFPageSizes = ({ doc }) => {
+ whiteboardPages.value.forEach((page) => {
+ const pageEl = document.getElementById(`${doc.id}-page-${page.pageIndex + 1}`);
+ if (!pageEl) return;
+ const pageBounds = pageEl.getBoundingClientRect();
+ const existingSize = whiteboardPageSizes[page.pageIndex] || {};
+ const originalWidth = existingSize.originalWidth ?? page.size?.originalWidth ?? pageBounds.width;
+ const originalHeight = existingSize.originalHeight ?? page.size?.originalHeight ?? pageBounds.height;
+ const size = {
+ width: pageBounds.width,
+ height: pageBounds.height,
+ originalWidth,
+ originalHeight,
+ };
+ whiteboardPageSizes[page.pageIndex] = size;
+ page.setSize(size);
+ });
+ };
+
+ // Recompute offsets for overlay positioning.
+ // NOTE: Coordinates are currently absolute (not normalized to page size).
+ const updateWhiteboardPageOffsets = () => {
+ const layerBounds = layers.value?.getBoundingClientRect?.();
+ if (!layerBounds) return;
+ const doc = documents.value?.[0];
+ if (!doc) return;
+ if (doc.type === PDF) {
+ updateWhiteboardPDFPageOffsets({ doc, layerBounds });
+ }
+ };
+
+ const updateWhiteboardPDFPageOffsets = ({ doc, layerBounds }) => {
+ whiteboardPages.value.forEach((page) => {
+ const pageEl = document.getElementById(`${doc.id}-page-${page.pageIndex + 1}`);
+ if (!pageEl) return;
+ const pageBounds = pageEl.getBoundingClientRect();
+ whiteboardPageOffsets[page.pageIndex] = {
+ top: pageBounds.top - layerBounds.top,
+ left: pageBounds.left - layerBounds.left,
+ };
+ });
+ };
+
+ if (whiteboard) {
+ whiteboard.on('change', handleWhiteboardChange);
+ whiteboard.on('setData', handleWhiteboardSetData);
+ whiteboard.on('enabled', handleWhiteboardEnabled);
+ whiteboard.on('opacity', handleWhiteboardOpacity);
+ whiteboard.on('tool', handleWhiteboardTool);
+ }
+
+ onBeforeUnmount(() => {
+ if (!whiteboard) return;
+ whiteboard.off('change', handleWhiteboardChange);
+ whiteboard.off('setData', handleWhiteboardSetData);
+ whiteboard.off('opacity', handleWhiteboardOpacity);
+ whiteboard.off('enabled', handleWhiteboardEnabled);
+ whiteboard.off('tool', handleWhiteboardTool);
+ });
+
+ return {
+ whiteboard,
+ whiteboardModuleConfig,
+ whiteboardPages,
+ whiteboardPageSizes,
+ whiteboardPageOffsets,
+ whiteboardEnabled,
+ whiteboardOpacity,
+ handleWhiteboardPageReady,
+ updateWhiteboardPageSizes,
+ updateWhiteboardPageOffsets,
+ };
+};
diff --git a/packages/superdoc/src/core/EventEmitter.ts b/packages/superdoc/src/core/EventEmitter.ts
new file mode 100644
index 0000000000..d15df67354
--- /dev/null
+++ b/packages/superdoc/src/core/EventEmitter.ts
@@ -0,0 +1,86 @@
+/**
+ * Default event map with string keys and any arguments.
+ * Using `any[]` is necessary here to allow flexible event argument types
+ * while maintaining type safety through generic constraints in EventEmitter.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type DefaultEventMap = Record
;
+
+/**
+ * Event callback function type.
+ * Using `any[]` default is necessary for variance and compatibility with event handlers.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type EventCallback = (...args: Args) => void;
+
+/**
+ * EventEmitter class is used to emit and subscribe to events.
+ * @template EventMap - Map of event names to their argument types
+ */
+export class EventEmitter {
+ #events = new Map();
+
+ /**
+ * Subscribe to the event.
+ * @param name Event name.
+ * @param fn Callback.
+ * @returns {void}
+ */
+ on(name: K, fn: EventCallback): void {
+ const callbacks = this.#events.get(name);
+ if (callbacks) callbacks.push(fn);
+ else this.#events.set(name, [fn]);
+ }
+
+ /**
+ * Emit event.
+ * @param name Event name.
+ * @param args Arguments to pass to each listener.
+ * @returns {void}
+ */
+ emit(name: K, ...args: EventMap[K]): void {
+ const callbacks = this.#events.get(name);
+ if (!callbacks) return;
+ for (const fn of callbacks) {
+ fn.apply(this, args);
+ }
+ }
+
+ /**
+ * Remove a specific callback from event
+ * or all event subscriptions.
+ * @param name Event name.
+ * @param fn Callback.
+ * @returns {void}
+ */
+ off(name: K, fn?: EventCallback): void {
+ const callbacks = this.#events.get(name);
+ if (!callbacks) return;
+ if (fn) {
+ this.#events.set(name, callbacks.filter((cb) => cb !== fn) as EventCallback[]);
+ } else {
+ this.#events.delete(name);
+ }
+ }
+
+ /**
+ * Subscribe to an event that will be called only once.
+ * @param name Event name.
+ * @param fn Callback.
+ * @returns {void}
+ */
+ once(name: K, fn: EventCallback): void {
+ const wrapper = (...args: EventMap[K]) => {
+ this.off(name, wrapper);
+ fn.apply(this, args);
+ };
+ this.on(name, wrapper);
+ }
+
+ /**
+ * Remove all registered events and subscriptions.
+ */
+ removeAllListeners(): void {
+ this.#events = new Map();
+ }
+}
diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js
index 9b65b04b92..da1d59fedf 100644
--- a/packages/superdoc/src/core/SuperDoc.js
+++ b/packages/superdoc/src/core/SuperDoc.js
@@ -14,6 +14,8 @@ import { initSuperdocYdoc, initCollaborationComments, makeDocumentsCollaborative
import { setupAwarenessHandler } from './collaboration/collaboration.js';
import { normalizeDocumentEntry } from './helpers/file.js';
import { isAllowed } from './collaboration/permissions.js';
+import { Whiteboard } from './whiteboard/Whiteboard';
+import { WhiteboardRenderer } from './whiteboard/WhiteboardRenderer';
const DEFAULT_USER = Object.freeze({
name: 'Default SuperDoc user',
@@ -54,6 +56,8 @@ export class SuperDoc extends EventEmitter {
/** @type {import('@hocuspocus/provider').HocuspocusProvider | undefined} */
provider;
+ whiteboard;
+
/** @type {Config} */
config = {
superdocId: null,
@@ -226,6 +230,7 @@ export class SuperDoc extends EventEmitter {
this.#initVueApp();
this.#initListeners();
+ this.#initWhiteboard();
this.user = this.config.user; // The current user
this.users = this.config.users || []; // All users who have access to this superdoc
@@ -253,6 +258,18 @@ export class SuperDoc extends EventEmitter {
this.#addToolbar();
}
+ #initWhiteboard() {
+ const config = this.config.modules?.whiteboard ?? {};
+ const enabled = config.enabled ?? false;
+
+ this.whiteboard = new Whiteboard({
+ Renderer: WhiteboardRenderer,
+ superdoc: this,
+ enabled,
+ });
+ this.emit('whiteboard:ready', { whiteboard: this.whiteboard });
+ }
+
/**
* Get the number of editors that are required for this superdoc
* @returns {number} The number of required editors
diff --git a/packages/superdoc/src/core/whiteboard/Whiteboard.js b/packages/superdoc/src/core/whiteboard/Whiteboard.js
new file mode 100644
index 0000000000..396a08f30d
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/Whiteboard.js
@@ -0,0 +1,252 @@
+import { EventEmitter } from '../EventEmitter';
+import { WhiteboardPage } from './WhiteboardPage';
+
+/**
+ * @typedef {{ width: number, height: number, originalWidth?: number, originalHeight?: number }} WhiteboardPageSize
+ * @typedef {{ strokes?: any[], text?: any[], images?: any[] }} WhiteboardPageData
+ * @typedef {{ pages?: Record }} WhiteboardData
+ * @typedef {{
+ * Renderer?: any,
+ * superdoc?: any,
+ * enabled?: boolean,
+ * onChange?: (data: WhiteboardData) => void,
+ * onSetData?: (pages: WhiteboardPage[]) => void,
+ * onEnabledChange?: (enabled: boolean) => void,
+ * }} WhiteboardInit
+ */
+
+/**
+ * Whiteboard manager for multi-page annotations.
+ */
+export class Whiteboard extends EventEmitter {
+ #Renderer = null;
+
+ #superdoc = null;
+
+ #pages = new Map();
+
+ #registry = new Map();
+
+ #currentTool = 'select';
+
+ #enabled = false;
+
+ #opacity = 1;
+
+ #onChange = null;
+
+ #onSetData = null;
+
+ #onEnabledChange = null;
+
+ /**
+ * Initialize the whiteboard instance.
+ * @param {WhiteboardInit} [props]
+ */
+ constructor(props = {}) {
+ super();
+ this.#init(props);
+ }
+
+ /**
+ * @private
+ * @param {WhiteboardInit} props
+ */
+ #init(props) {
+ this.#Renderer = props.Renderer;
+ this.#superdoc = props.superdoc;
+ this.#enabled = props.enabled;
+
+ this.#onChange = props.onChange;
+ this.#onSetData = props.onSetData;
+ this.#onEnabledChange = props.onEnabledChange;
+ }
+
+ /**
+ * Register items for a UI palette type (e.g. stickers, comments).
+ * @param {string} type
+ * @param {any[]} items
+ */
+ register(type, items) {
+ this.#registry.set(type, items);
+ }
+
+ /**
+ * Get registered items by type.
+ * @param {string} type
+ * @returns {any[] | undefined}
+ */
+ getType(type) {
+ return this.#registry.get(type);
+ }
+
+ /**
+ * Set current tool for all pages.
+ * @param {string} tool
+ */
+ setTool(tool) {
+ this.#currentTool = tool;
+ this.#pages.forEach((page) => page.setTool(tool));
+ this.emit('tool', tool);
+ }
+
+ /**
+ * Get current tool.
+ * @returns {string}
+ */
+ getTool() {
+ return this.#currentTool;
+ }
+
+ /**
+ * Enable/disable interactivity for all pages.
+ * @param {boolean} enabled
+ */
+ setEnabled(enabled) {
+ this.#enabled = enabled;
+ this.#pages.forEach((page) => page.setEnabled(this.#enabled));
+ this.emit('enabled', this.#enabled);
+ if (this.#onEnabledChange) {
+ this.#onEnabledChange(this.#enabled);
+ }
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ isEnabled() {
+ return this.#enabled;
+ }
+
+ /**
+ * Set overlay opacity.
+ * @param {number} opacity
+ */
+ setOpacity(opacity) {
+ const value = opacity;
+ this.#opacity = Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 1;
+ this.emit('opacity', this.#opacity);
+ }
+
+ /**
+ * @returns {number}
+ */
+ getOpacity() {
+ return this.#opacity;
+ }
+
+ /**
+ * Return all page instances.
+ * @returns {WhiteboardPage[]}
+ */
+ getPages() {
+ return Array.from(this.#pages.values());
+ }
+
+ /**
+ * Set size for a page (creates page if missing).
+ * @param {number} pageIndex
+ * @param {WhiteboardPageSize} size
+ */
+ setPageSize(pageIndex, size) {
+ const page = this.#createPage(pageIndex);
+ page.setSize(size);
+ }
+
+ /**
+ * @private
+ * Create a page if it doesn't exist.
+ * @param {number} pageIndex
+ * @returns {WhiteboardPage}
+ */
+ #createPage(pageIndex) {
+ const existing = this.#pages.get(pageIndex);
+ if (existing) return existing;
+ const page = new WhiteboardPage({
+ Renderer: this.#Renderer,
+ enabled: this.#enabled,
+ pageIndex,
+ onChange: () => {
+ this.#emitChange();
+ },
+ onToolChange: (tool) => {
+ this.setTool(tool);
+ },
+ });
+ page.setTool(this.#currentTool);
+ page.setEnabled(this.#enabled);
+ this.#pages.set(pageIndex, page);
+ return page;
+ }
+
+ /**
+ * Get a page by index.
+ * @param {number} pageIndex
+ * @returns {WhiteboardPage}
+ */
+ getPage(pageIndex) {
+ return this.#pages.get(pageIndex);
+ }
+
+ /**
+ * Serialize whiteboard data.
+ * @returns {WhiteboardData}
+ */
+ getWhiteboardData() {
+ const pages = {};
+ const pageSizes = {};
+
+ for (const page of this.#pages.values()) {
+ pages[page.pageIndex] = page.toJSON();
+ if (page.size) {
+ pageSizes[page.pageIndex] = {
+ width: page.size.width,
+ height: page.size.height,
+ originalWidth: page.originalSize?.width ?? null,
+ originalHeight: page.originalSize?.height ?? null,
+ };
+ }
+ }
+
+ const data = {
+ pages,
+ meta: { pageSizes },
+ version: 1,
+ };
+
+ return data;
+ }
+
+ /**
+ * Load whiteboard data from JSON.
+ * @param {WhiteboardData} json
+ */
+ setWhiteboardData(json) {
+ this.#pages.clear();
+
+ const pages = json?.pages || {};
+ Object.keys(pages).forEach((key) => {
+ const parsedIndex = Number(key);
+ const pageIndex = Number.isNaN(parsedIndex) ? key : parsedIndex;
+ const page = this.#createPage(pageIndex);
+ page.applyData(pages[key]);
+ });
+
+ this.emit('setData', this.getPages());
+ if (this.#onSetData) {
+ this.#onSetData(this.getPages());
+ }
+
+ this.#emitChange();
+ }
+
+ /**
+ * @private
+ * Emit change events.
+ */
+ #emitChange() {
+ const data = this.getWhiteboardData();
+ this.emit('change', data);
+ if (this.#onChange) this.#onChange(data);
+ }
+}
diff --git a/packages/superdoc/src/core/whiteboard/Whiteboard.test.js b/packages/superdoc/src/core/whiteboard/Whiteboard.test.js
new file mode 100644
index 0000000000..a79e1227b5
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/Whiteboard.test.js
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi } from 'vitest';
+import { Whiteboard } from './Whiteboard';
+
+describe('Whiteboard', () => {
+ it('register/getType returns stored items', () => {
+ const wb = new Whiteboard();
+ const stickers = [{ id: 'a', src: '/a.png' }];
+ wb.register('stickers', stickers);
+ expect(wb.getType('stickers')).toBe(stickers);
+ expect(wb.getType('unknown')).toBeUndefined();
+ });
+
+ it('getWhiteboardData returns per-page JSON', () => {
+ const wb = new Whiteboard();
+ wb.setWhiteboardData({
+ pages: {
+ 0: {
+ strokes: [{ points: [[1, 2]] }],
+ text: [{ id: 't1', x: 1, y: 2, content: 'hi' }],
+ images: [{ id: 'i1', x: 1, y: 2, src: '/x.png' }],
+ },
+ 1: {
+ strokes: [
+ {
+ points: [
+ [3, 4],
+ [5, 6],
+ ],
+ },
+ ],
+ text: [{ id: 't2', x: 10, y: 20, content: 'bye' }],
+ images: [{ id: 'i2', x: 2, y: 3, src: '/y.png', width: 50, height: 60 }],
+ },
+ },
+ });
+
+ const data = wb.getWhiteboardData();
+ expect(data.pages['0'].strokes.length).toBe(1);
+ expect(data.pages['0'].text.length).toBe(1);
+ expect(data.pages['0'].images.length).toBe(1);
+ expect(data.pages['1'].strokes.length).toBe(1);
+ expect(data.pages['1'].text[0].content).toBe('bye');
+ expect(data.pages['1'].images[0].width).toBe(50);
+ });
+
+ it('setWhiteboardData emits setData and change', () => {
+ const wb = new Whiteboard();
+ const onSetData = vi.fn();
+ const onChange = vi.fn();
+ wb.on('setData', onSetData);
+ wb.on('change', onChange);
+
+ wb.setWhiteboardData({
+ pages: {
+ 1: {
+ strokes: [],
+ text: [],
+ images: [],
+ },
+ 2: {
+ strokes: [{ points: [[7, 8]] }],
+ text: [{ id: 't3', x: 5, y: 6, content: 'note' }],
+ images: [],
+ },
+ },
+ });
+
+ expect(onSetData).toHaveBeenCalledOnce();
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+
+ it('setWhiteboardData clears previous pages', () => {
+ const wb = new Whiteboard();
+ wb.setWhiteboardData({ pages: { 0: { strokes: [], text: [], images: [] } } });
+ wb.setWhiteboardData({ pages: { 1: { strokes: [], text: [], images: [] } } });
+ const data = wb.getWhiteboardData();
+ expect(data.pages['0']).toBeUndefined();
+ expect(data.pages['1']).toBeDefined();
+ });
+
+ it('emits change when a page mutates', () => {
+ const wb = new Whiteboard();
+ const onChange = vi.fn();
+ wb.on('change', onChange);
+
+ wb.setWhiteboardData({ pages: { 0: { strokes: [], text: [], images: [] } } });
+ const page = wb.getPage(0);
+ page.addText({ x: 1, y: 2, content: 'hello' });
+
+ expect(onChange).toHaveBeenCalled();
+ });
+});
diff --git a/packages/superdoc/src/core/whiteboard/WhiteboardPage.js b/packages/superdoc/src/core/whiteboard/WhiteboardPage.js
new file mode 100644
index 0000000000..9db9ca99d9
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/WhiteboardPage.js
@@ -0,0 +1,889 @@
+import { flattenPoints } from './helpers/flattenPoints';
+import { getRandomId } from './helpers/getRandomId';
+import { createTextarea } from './helpers/createTextarea';
+
+/**
+ * @typedef {{ width: number, height: number, originalWidth?: number, originalHeight?: number }} WhiteboardPageSize
+ * @typedef {{ x: number, y: number }} Point
+ * @typedef {{ points: number[][], color?: string, width?: number, type?: 'draw'|'erase' }} WhiteboardStroke
+ * @typedef {{ id?: string|number, x: number, y: number, content: string, fontSize?: number, width?: number }} WhiteboardTextItem
+ * @typedef {{ id?: string|number, stickerId?: string, x: number, y: number, src: string, width?: number, height?: number, type?: string }} WhiteboardImageItem
+ * @typedef {{
+ * pageIndex: number,
+ * enabled: boolean,
+ * Renderer: any,
+ * onChange?: () => void,
+ * onToolChange?: (tool: string) => void,
+ * }} WhiteboardPageInit
+ */
+
+/**
+ * Per-page whiteboard renderer/controller.
+ */
+export class WhiteboardPage {
+ /** @type {number|null} */
+ pageIndex = null;
+
+ /** @type {WhiteboardStroke[]} */
+ strokes = [];
+
+ /** @type {WhiteboardTextItem[]} */
+ text = [];
+
+ /** @type {WhiteboardImageItem[]} */
+ images = [];
+
+ /** @type {WhiteboardPageSize|null} */
+ size = null;
+
+ /** @type {{ width: number|null, height: number|null }|null} */
+ originalSize = null;
+
+ #Renderer = null;
+
+ #stage = null;
+
+ #layer = null;
+
+ #strokesLayer = null;
+
+ #transformer = null;
+
+ #containerEl = null;
+
+ #isDrawing = false;
+
+ #currentLine = null;
+
+ #currentPoints = [];
+
+ #currentTool = 'select';
+
+ #enabled = false;
+
+ #selectedNode = null;
+
+ #strokeColor = '#2293fb';
+
+ #strokeWidth = 5;
+
+ #onChange = null;
+
+ #onToolChange = null;
+
+ /**
+ * Create a page controller.
+ * @param {WhiteboardPageInit} props
+ */
+ constructor(props) {
+ this.#init(props);
+ }
+
+ /**
+ * @private
+ * Initialize internal state.
+ * @param {WhiteboardPageInit} props
+ */
+ #init(props) {
+ this.#Renderer = props.Renderer;
+ this.#enabled = props.enabled;
+ this.pageIndex = props.pageIndex;
+
+ this.#onChange = props.onChange;
+ this.#onToolChange = props.onToolChange;
+ }
+
+ /**
+ * @private
+ * Attach Konva stage listeners.
+ */
+ #attachEventListeners() {
+ if (!this.#stage || !this.#layer) {
+ return;
+ }
+
+ this.#stage.on('mousedown touchstart', (event) => {
+ this.#handleDrawStart(event);
+ });
+
+ this.#stage.on('mousemove touchmove', (event) => {
+ this.#handleDrawMove(event);
+ });
+
+ this.#stage.on('mouseup touchend', (event) => {
+ this.#handleDrawEnd(event);
+ });
+
+ this.#stage.on('click tap', (event) => {
+ this.#handleStageClick(event);
+ });
+
+ window.addEventListener('keydown', this.#handleKeydown);
+ }
+
+ /**
+ * Set tool for this page.
+ * @param {string} tool
+ */
+ setTool(tool) {
+ this.#currentTool = tool;
+ this.#updateStrokesLayerListening();
+ this.#clearSelection();
+ this.render();
+ }
+
+ /**
+ * Get current tool for this page.
+ * @returns {string}
+ */
+ getTool() {
+ return this.#currentTool;
+ }
+
+ /**
+ * Enable/disable interactivity for this page.
+ * @param {boolean} enabled
+ */
+ setEnabled(enabled) {
+ this.#enabled = Boolean(enabled);
+ this.#updateStrokesLayerListening();
+ this.#clearSelection();
+ this.render();
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ isEnabled() {
+ return this.#enabled;
+ }
+
+ /**
+ * Store page size (does not resize canvas).
+ * @param {WhiteboardPageSize} size
+ */
+ setSize(size) {
+ if (!size) return;
+ const { width, height, originalWidth, originalHeight } = size;
+ this.size = { width, height };
+ this.originalSize = { width: originalWidth ?? null, height: originalHeight ?? null };
+ }
+
+ /**
+ * Resize the Konva stage.
+ * @param {number} width
+ * @param {number} height
+ */
+ resize(width, height) {
+ if (!this.#stage) return;
+ this.#stage.size({ width, height });
+ this.#applyPixelRatio();
+ }
+
+ /**
+ * Mount Konva stage into a container.
+ * @param {HTMLElement} container
+ */
+ mount(container) {
+ if (!container) {
+ return;
+ }
+ if (this.#stage && this.#containerEl === container) {
+ return;
+ }
+
+ if (this.#stage) {
+ this.destroy();
+ }
+
+ const width = this.size?.width || container.clientWidth || 1;
+ const height = this.size?.height || container.clientHeight || 1;
+
+ this.#containerEl = container;
+ this.#stage = new this.#Renderer.Stage({ container, width, height });
+ this.#layer = new this.#Renderer.Layer();
+ this.#strokesLayer = new this.#Renderer.Layer();
+ this.#stage.add(this.#layer);
+ this.#stage.add(this.#strokesLayer);
+ this.#applyPixelRatio();
+
+ this.#updateStrokesLayerListening();
+ this.#attachEventListeners();
+
+ this.render();
+ }
+
+ /**
+ * Re-render all content for this page.
+ */
+ render() {
+ if (!this.#layer) {
+ return;
+ }
+
+ if (this.#transformer) {
+ this.#transformer.destroy();
+ this.#transformer = null;
+ }
+
+ const texts = this.#layer.find('.wb-text');
+ texts.forEach((node) => node.destroy());
+ this.#strokesLayer.destroyChildren();
+
+ this.renderStrokes();
+ this.renderText();
+ this.renderImages();
+
+ this.#layer.batchDraw();
+ this.#strokesLayer.batchDraw();
+ }
+
+ /**
+ * @private
+ * Reduce canvas memory by limiting device pixel ratio.
+ */
+ #applyPixelRatio() {
+ const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
+ if (this.#layer?.getCanvas) {
+ this.#layer.getCanvas().setPixelRatio(ratio);
+ }
+ if (this.#strokesLayer?.getCanvas) {
+ this.#strokesLayer.getCanvas().setPixelRatio(ratio);
+ }
+ }
+
+ /**
+ * @private
+ * Enable stroke layer hit-testing only in draw/erase.
+ * NOTE: If listening is enabled outside draw/erase, strokes can block selecting items beneath.
+ */
+ #updateStrokesLayerListening() {
+ if (!this.#strokesLayer) return;
+ const isDrawMode = this.#currentTool === 'draw' || this.#currentTool === 'erase';
+ const isListening = Boolean(this.#enabled && isDrawMode);
+ this.#strokesLayer.listening(isListening);
+ }
+
+ /**
+ * Render stroke paths.
+ */
+ renderStrokes() {
+ this.strokes.forEach((stroke) => {
+ const line = new this.#Renderer.Line({
+ points: flattenPoints(stroke.points || []),
+ stroke: stroke.color || this.#strokeColor,
+ strokeWidth: stroke.width || this.#strokeWidth,
+ lineCap: 'round',
+ lineJoin: 'round',
+ globalCompositeOperation: stroke.type === 'erase' ? 'destination-out' : 'source-over',
+ });
+ line.name('wb-stroke');
+ this.#strokesLayer.add(line);
+ });
+ }
+
+ /**
+ * Render text nodes.
+ */
+ renderText() {
+ this.text.forEach((item) => {
+ const textNode = new this.#Renderer.Text({
+ x: item.x,
+ y: item.y,
+ text: item.content,
+ fontSize: item.fontSize ?? 18,
+ fontFamily: 'Arial, sans-serif',
+ fill: '#2293fb',
+ draggable: this.#currentTool === 'select',
+ width: item.width ?? undefined,
+ });
+
+ textNode.name('wb-text');
+ textNode._whiteboardId = item.id;
+ this.#attachTextNodeEvents(textNode, item);
+
+ this.#layer.add(textNode);
+ });
+ }
+
+ /**
+ * @private
+ * Attach text node events.
+ * @param {any} textNode
+ * @param {WhiteboardTextItem} item
+ */
+ #attachTextNodeEvents(textNode, item) {
+ textNode.on('click tap', (event) => {
+ if (this.#currentTool !== 'select' || !this.#enabled) return;
+ event.cancelBubble = true;
+ this.#selectNode(textNode);
+ });
+
+ textNode.on('dragend', () => {
+ item.x = textNode.x();
+ item.y = textNode.y();
+ this.#triggerChanged();
+ });
+
+ textNode.on('transform', () => {
+ textNode.scaleY(1);
+ textNode.setAttrs({
+ width: textNode.width() * textNode.scaleX(),
+ scaleX: 1,
+ });
+ });
+
+ textNode.on('transformend', () => {
+ textNode.setAttrs({
+ width: Math.max(1, textNode.width() * textNode.scaleX()),
+ scaleX: 1,
+ scaleY: 1,
+ });
+ const nextWidth = textNode.width();
+ item.width = nextWidth;
+ item.x = textNode.x();
+ item.y = textNode.y();
+ this.#triggerChanged();
+ });
+
+ textNode.on('dblclick dbltap', (event) => {
+ if (this.#currentTool !== 'select' || !this.#enabled) return;
+ event.cancelBubble = true;
+ this.#editTextNode(textNode, item);
+ });
+ }
+
+ /**
+ * Render image/sticker nodes.
+ * NOTE: Images are reconciled by id to avoid re-creating nodes (prevents flicker).
+ * If src changes for an existing id, this path does not reload the image.
+ */
+ renderImages() {
+ const existingNodes = this.#layer?.find('.wb-image') ?? [];
+ const existingById = new Map();
+
+ existingNodes.forEach((node) => {
+ existingById.set(node._whiteboardId, node);
+ });
+
+ const imageIds = new Set(this.images.map((item) => item.id));
+ existingNodes.forEach((node) => {
+ if (!imageIds.has(node._whiteboardId)) {
+ node.destroy();
+ }
+ });
+
+ const renderImageItem = (item, name) => {
+ const existing = existingById.get(item.id);
+
+ if (existing) {
+ if (Number.isFinite(item.x)) existing.x(item.x);
+ if (Number.isFinite(item.y)) existing.y(item.y);
+ if (Number.isFinite(item.width)) existing.width(item.width);
+ if (Number.isFinite(item.height)) existing.height(item.height);
+ existing.draggable(this.#currentTool === 'select');
+ return;
+ }
+
+ const imageObj = new window.Image();
+ imageObj.crossOrigin = 'Anonymous';
+ imageObj.onload = () => {
+ const imageNode = new this.#Renderer.Image({
+ x: item.x,
+ y: item.y,
+ image: imageObj,
+ width: item.width ?? imageObj.width,
+ height: item.height ?? imageObj.height,
+ draggable: this.#currentTool === 'select',
+ });
+
+ imageNode.name(name);
+ imageNode._whiteboardId = item.id;
+ this.#attachImageNodeEvents(imageNode, item);
+
+ this.#layer.add(imageNode);
+ this.#layer.batchDraw();
+ };
+ imageObj.src = item.src;
+ };
+
+ this.images.forEach((item) => renderImageItem(item, 'wb-image'));
+ }
+
+ /**
+ * @private
+ * Attach image node events.
+ * @param {any} imageNode
+ * @param {WhiteboardImageItem} item
+ */
+ #attachImageNodeEvents(imageNode, item) {
+ imageNode.on('click tap', (event) => {
+ if (this.#currentTool !== 'select' || !this.#enabled) return;
+ event.cancelBubble = true;
+ this.#selectNode(imageNode);
+ });
+
+ imageNode.on('dragend', () => {
+ item.x = imageNode.x();
+ item.y = imageNode.y();
+ this.#triggerChanged();
+ });
+
+ imageNode.on('transformend', () => {
+ const scaleX = imageNode.scaleX();
+ const scaleY = imageNode.scaleY();
+ const nextWidth = Math.max(1, imageNode.width() * scaleX);
+ const nextHeight = Math.max(1, imageNode.height() * scaleY);
+ imageNode.scale({ x: 1, y: 1 });
+ imageNode.width(nextWidth);
+ imageNode.height(nextHeight);
+ item.width = nextWidth;
+ item.height = nextHeight;
+ item.x = imageNode.x();
+ item.y = imageNode.y();
+ this.#triggerChanged();
+ });
+ }
+
+ /**
+ * Destroy Konva stage and clean up listeners.
+ */
+ destroy() {
+ if (this.#stage) {
+ this.#stage.destroy();
+ }
+
+ this.#stage = null;
+ this.#layer = null;
+ this.#strokesLayer = null;
+ this.#containerEl = null;
+ this.#isDrawing = false;
+ this.#currentLine = null;
+ this.#currentPoints = [];
+ this.#selectedNode = null;
+ this.#transformer = null;
+
+ window.removeEventListener('keydown', this.#handleKeydown);
+ }
+
+ /**
+ * Serialize page data.
+ * @returns {{ strokes: any[], text: any[], stickers: any[] }}
+ */
+ toJSON() {
+ return {
+ strokes: this.strokes,
+ text: this.text,
+ images: this.images,
+ };
+ }
+
+ /**
+ * Apply data to this page and re-render.
+ * @param {{ strokes?: any[], text?: any[], images?: any[] }} data
+ */
+ applyData(data = {}) {
+ const strokes = Array.isArray(data.strokes) ? data.strokes : [];
+ const text = Array.isArray(data.text) ? data.text : [];
+ const images = Array.isArray(data.images) ? data.images : [];
+
+ this.strokes = strokes;
+ this.text = text;
+ this.images = images;
+
+ this.render();
+ }
+
+ /**
+ * @private
+ * Notify change listeners.
+ */
+ #triggerChanged() {
+ if (this.#onChange) {
+ this.#onChange();
+ }
+ }
+
+ /**
+ * Add a stroke to the model.
+ * @param {WhiteboardStroke} stroke
+ */
+ addStroke(stroke) {
+ if (!stroke || !Array.isArray(stroke.points)) {
+ return;
+ }
+ this.strokes.push(stroke);
+ }
+
+ /**
+ * Add a text item to the model.
+ * @param {WhiteboardTextItem} item
+ */
+ addText(item) {
+ if (!item || typeof item.content !== 'string') {
+ return;
+ }
+
+ this.text.push({
+ id: item.id ?? getRandomId('text'),
+ x: item.x,
+ y: item.y,
+ content: item.content,
+ fontSize: item.fontSize ?? 18,
+ width: item.width ?? null,
+ });
+
+ this.render();
+ this.#triggerChanged();
+ }
+
+ /**
+ * Add an image/sticker to the model.
+ * @param {WhiteboardImageItem} item
+ */
+ addImage(item) {
+ if (!item || !item.src) {
+ return;
+ }
+
+ const imageItem = {
+ id: item.id ?? getRandomId('image'),
+ stickerId: item.stickerId ?? (item.type === 'sticker' ? (item.id ?? null) : null),
+ x: item.x,
+ y: item.y,
+ src: item.src,
+ width: item.width ?? null,
+ height: item.height ?? null,
+ type: item.type ?? 'image',
+ };
+ this.images.push(imageItem);
+
+ this.render();
+ this.#triggerChanged();
+ }
+
+ /**
+ * @private
+ * Clear current selection/transformer.
+ */
+ #clearSelection() {
+ if (!this.#transformer) return;
+ this.#transformer.destroy();
+ this.#transformer = null;
+ this.#selectedNode = null;
+ this.#layer?.batchDraw();
+ }
+
+ /**
+ * @private
+ * Select a node and attach transformer.
+ * @param {any} node
+ */
+ #selectNode(node) {
+ if (!this.#layer) {
+ return;
+ }
+
+ this.#selectedNode = node;
+
+ const isText = node.name && node.name() === 'wb-text';
+ const textAnchors = ['middle-left', 'middle-right'];
+ const imageAnchors = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
+ const anchors = isText ? textAnchors : imageAnchors;
+
+ const boundBoxFunc = (oldBox, newBox) => {
+ const min = 20;
+ if (newBox.width < min) return oldBox;
+ if (isText) {
+ return {
+ ...newBox,
+ height: oldBox.height,
+ y: oldBox.y,
+ };
+ }
+ if (newBox.height < min) return oldBox;
+ return newBox;
+ };
+
+ if (!this.#transformer) {
+ this.#transformer = new this.#Renderer.Transformer({
+ nodes: [node],
+ enabledAnchors: anchors,
+ rotateEnabled: false,
+ keepRatio: false,
+ boundBoxFunc,
+ });
+ this.#layer.add(this.#transformer);
+ } else {
+ this.#transformer.nodes([node]);
+ this.#transformer.enabledAnchors(anchors);
+ this.#transformer.boundBoxFunc(boundBoxFunc);
+ }
+
+ this.#layer.batchDraw();
+ }
+
+ /**
+ * @private
+ * Delete currently selected node.
+ */
+ #deleteSelectedNode() {
+ if (!this.#selectedNode) {
+ return;
+ }
+
+ const node = this.#selectedNode;
+ const name = node.name && node.name();
+
+ const deleteHandlers = {
+ 'wb-text': () => {
+ const id = node._whiteboardId;
+ this.text = this.text.filter((item) => item.id !== id);
+ },
+ 'wb-image': () => {
+ const id = node._whiteboardId;
+ this.images = this.images.filter((item) => item.id !== id);
+ },
+ };
+ const handler = deleteHandlers[name];
+
+ if (handler) handler();
+ node.destroy();
+
+ this.#clearSelection();
+ this.render();
+ }
+
+ /**
+ * @private
+ * Handle keydown for delete/backspace.
+ * @param {KeyboardEvent} event
+ */
+ #handleKeydown = (event) => {
+ if (!this.#selectedNode || !this.#enabled || this.#currentTool !== 'select') {
+ return;
+ }
+
+ const targetTag = event.target?.tagName;
+ if (targetTag === 'INPUT' || targetTag === 'TEXTAREA') {
+ return;
+ }
+
+ if (event.key === 'Backspace' || event.key === 'Delete') {
+ event.preventDefault();
+ this.#deleteSelectedNode();
+ this.#triggerChanged();
+ }
+ };
+
+ /**
+ * @private
+ * Handle stage click/tap.
+ * @param {any} event
+ */
+ #handleStageClick(event) {
+ if (!this.#enabled || !this.#stage) {
+ return;
+ }
+
+ const handlers = {
+ text: () => {
+ const pos = this.#stage.getPointerPosition();
+ if (!pos) return;
+ this.#startTextInput(pos.x, pos.y);
+ },
+ select: () => {
+ if (event?.target === this.#stage) {
+ this.#clearSelection();
+ }
+ },
+ };
+
+ const handler = handlers[this.#currentTool];
+ if (handler) handler();
+ }
+
+ /**
+ * @private
+ * Begin drawing/erasing.
+ */
+ #handleDrawStart() {
+ if (!this.#enabled || (this.#currentTool !== 'draw' && this.#currentTool !== 'erase')) {
+ return;
+ }
+ this.#isDrawing = true;
+ const pos = this.#stage.getPointerPosition();
+ if (!pos) return;
+ const isErase = this.#currentTool === 'erase';
+ this.#currentPoints = [pos.x, pos.y];
+ this.#currentLine = new this.#Renderer.Line({
+ points: this.#currentPoints,
+ stroke: this.#strokeColor,
+ strokeWidth: isErase ? this.#strokeWidth * 3 : this.#strokeWidth,
+ lineCap: 'round',
+ lineJoin: 'round',
+ globalCompositeOperation: isErase ? 'destination-out' : 'source-over',
+ });
+ this.#strokesLayer.add(this.#currentLine);
+ }
+
+ /**
+ * @private
+ * Continue drawing/erasing.
+ */
+ #handleDrawMove() {
+ if (!this.#enabled || (this.#currentTool !== 'draw' && this.#currentTool !== 'erase')) {
+ return;
+ }
+ if (!this.#isDrawing || !this.#currentLine) {
+ return;
+ }
+ const pos = this.#stage.getPointerPosition();
+ if (!pos) return;
+ this.#currentPoints.push(pos.x, pos.y);
+ this.#currentLine.points(this.#currentPoints);
+ this.#strokesLayer.batchDraw();
+ }
+
+ /**
+ * @private
+ * Finish drawing/erasing.
+ */
+ #handleDrawEnd() {
+ if (!this.#enabled || (this.#currentTool !== 'draw' && this.#currentTool !== 'erase')) {
+ return;
+ }
+ if (!this.#isDrawing || !this.#currentLine) {
+ return;
+ }
+ this.#isDrawing = false;
+ const pairs = [];
+ for (let i = 0; i < this.#currentPoints.length; i += 2) {
+ pairs.push([this.#currentPoints[i], this.#currentPoints[i + 1]]);
+ }
+ const isErase = this.#currentTool === 'erase';
+ this.addStroke({
+ points: pairs,
+ color: this.#strokeColor,
+ width: isErase ? this.#strokeWidth * 3 : this.#strokeWidth,
+ type: isErase ? 'erase' : 'draw',
+ });
+ this.#triggerChanged();
+ this.#currentLine = null;
+ this.#currentPoints = [];
+ }
+
+ /**
+ * @private
+ * Start text input at coordinates.
+ * @param {number} x
+ * @param {number} y
+ * @param {string} [initialValue]
+ */
+ #startTextInput(x, y) {
+ if (!this.#containerEl) {
+ return;
+ }
+
+ const textarea = createTextarea({
+ left: x,
+ top: y,
+ height: 24,
+ background: 'transparent',
+ fontSize: 18,
+ color: '#2293fb',
+ });
+ this.#containerEl.append(textarea);
+
+ textarea.focus();
+ textarea.select();
+
+ let finished = false;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ const text = textarea.value.trim();
+ if (text) {
+ this.addText({ x, y, content: text });
+ if (this.#currentTool === 'text' && this.#onToolChange) {
+ this.#onToolChange('select');
+ }
+ }
+ if (textarea.parentNode === this.#containerEl) {
+ this.#containerEl.removeChild(textarea);
+ }
+ };
+
+ textarea.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ finish();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ finish();
+ }
+ });
+ textarea.addEventListener('blur', finish);
+ }
+
+ /**
+ * @private
+ * Edit an existing text node.
+ * @param {any} textNode
+ * @param {WhiteboardTextItem} item
+ */
+ #editTextNode(textNode, item) {
+ if (!this.#containerEl || !this.#stage) {
+ return;
+ }
+
+ this.#clearSelection();
+
+ const textPosition = textNode.position();
+ const textarea = createTextarea({
+ value: textNode.text(),
+ left: textPosition.x,
+ top: textPosition.y,
+ width: Math.max(textNode.width(), 120),
+ height: Math.max(textNode.height(), 24),
+ fontSize: textNode.fontSize(),
+ fontFamily: textNode.fontFamily(),
+ color: textNode.fill ? textNode.fill() : '#2293fb',
+ background: 'white',
+ resize: 'both',
+ });
+ this.#containerEl.appendChild(textarea);
+
+ textarea.focus();
+ textarea.select();
+
+ let finished = false;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ const value = textarea.value.trim();
+ if (value) {
+ textNode.text(value);
+ item.content = value;
+ this.#layer.batchDraw();
+ this.#triggerChanged();
+ }
+ if (textarea.parentNode) {
+ textarea.parentNode.removeChild(textarea);
+ }
+ };
+
+ textarea.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ finish();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ finish();
+ }
+ });
+ textarea.addEventListener('blur', finish);
+ }
+}
diff --git a/packages/superdoc/src/core/whiteboard/WhiteboardRenderer.js b/packages/superdoc/src/core/whiteboard/WhiteboardRenderer.js
new file mode 100644
index 0000000000..fdecf38439
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/WhiteboardRenderer.js
@@ -0,0 +1,3 @@
+import Konva from 'konva';
+
+export { Konva as WhiteboardRenderer };
diff --git a/packages/superdoc/src/core/whiteboard/helpers/createTextarea.js b/packages/superdoc/src/core/whiteboard/helpers/createTextarea.js
new file mode 100644
index 0000000000..f513273e08
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/helpers/createTextarea.js
@@ -0,0 +1,38 @@
+export function createTextarea(options = {}) {
+ const {
+ value = '',
+ left,
+ top,
+ width,
+ height,
+ fontSize = 16,
+ fontFamily = 'Arial, sans-serif',
+ color = '#000',
+ background = 'transparent',
+ resize = 'none',
+ } = options;
+
+ const textarea = document.createElement('textarea');
+ textarea.value = value;
+
+ const style = textarea.style;
+ style.position = 'absolute';
+ if (left != null) style.left = `${left}px`;
+ if (top != null) style.top = `${top}px`;
+ if (width != null) style.width = typeof width === 'number' ? `${width}px` : width;
+ if (height != null) style.height = typeof height === 'number' ? `${height}px` : height;
+ style.minWidth = 120;
+ style.fontSize = typeof fontSize === 'number' ? `${fontSize}px` : fontSize;
+ style.fontFamily = fontFamily;
+ style.color = color;
+ style.background = background;
+ style.border = '2px solid #3c97fe80';
+ style.padding = '2px 4px';
+ style.margin = '0';
+ style.zIndex = '1000';
+ style.overflow = 'hidden';
+ style.resize = resize;
+ style.outline = 'none';
+
+ return textarea;
+}
diff --git a/packages/superdoc/src/core/whiteboard/helpers/createTextarea.test.js b/packages/superdoc/src/core/whiteboard/helpers/createTextarea.test.js
new file mode 100644
index 0000000000..d00a4089fe
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/helpers/createTextarea.test.js
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { createTextarea } from './createTextarea';
+
+describe('createTextarea', () => {
+ it('creates a textarea element with default values', () => {
+ const textarea = createTextarea();
+ expect(textarea.tagName).toBe('TEXTAREA');
+ expect(textarea.value).toBe('');
+ expect(textarea.style.position).toBe('absolute');
+ expect(textarea.style.fontSize).toBe('16px');
+ expect(textarea.style.fontFamily).toBe('Arial, sans-serif');
+ expect(textarea.style.color).toBe('#000');
+ });
+
+ it('applies provided position and size', () => {
+ const textarea = createTextarea({ left: 10, top: 20, width: 200, height: 50 });
+ expect(textarea.style.left).toBe('10px');
+ expect(textarea.style.top).toBe('20px');
+ expect(textarea.style.width).toBe('200px');
+ expect(textarea.style.height).toBe('50px');
+ });
+
+ it('accepts string sizes and custom styling', () => {
+ const textarea = createTextarea({
+ width: '12rem',
+ height: '3rem',
+ fontSize: '14pt',
+ fontFamily: 'serif',
+ color: '#123456',
+ background: '#fff',
+ resize: 'vertical',
+ });
+ expect(textarea.style.width).toBe('12rem');
+ expect(textarea.style.height).toBe('3rem');
+ expect(textarea.style.fontSize).toBe('14pt');
+ expect(textarea.style.fontFamily).toBe('serif');
+ expect(textarea.style.color).toBe('#123456');
+ expect(textarea.style.background).toBe('#fff');
+ expect(textarea.style.resize).toBe('vertical');
+ });
+});
diff --git a/packages/superdoc/src/core/whiteboard/helpers/flattenPoints.js b/packages/superdoc/src/core/whiteboard/helpers/flattenPoints.js
new file mode 100644
index 0000000000..ac6e9d8f0d
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/helpers/flattenPoints.js
@@ -0,0 +1,3 @@
+export function flattenPoints(points) {
+ return points.flatMap((pair) => [pair[0], pair[1]]);
+}
diff --git a/packages/superdoc/src/core/whiteboard/helpers/flattenPoints.test.js b/packages/superdoc/src/core/whiteboard/helpers/flattenPoints.test.js
new file mode 100644
index 0000000000..218d29b2d6
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/helpers/flattenPoints.test.js
@@ -0,0 +1,21 @@
+import { describe, it, expect } from 'vitest';
+import { flattenPoints } from './flattenPoints';
+
+describe('flattenPoints', () => {
+ it('returns empty array for empty input', () => {
+ expect(flattenPoints([])).toEqual([]);
+ });
+
+ it('flattens a single point pair', () => {
+ expect(flattenPoints([[10, 20]])).toEqual([10, 20]);
+ });
+
+ it('flattens multiple point pairs in order', () => {
+ const points = [
+ [1, 2],
+ [3, 4],
+ [5, 6],
+ ];
+ expect(flattenPoints(points)).toEqual([1, 2, 3, 4, 5, 6]);
+ });
+});
diff --git a/packages/superdoc/src/core/whiteboard/helpers/getRandomId.js b/packages/superdoc/src/core/whiteboard/helpers/getRandomId.js
new file mode 100644
index 0000000000..5eb4ca26b5
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/helpers/getRandomId.js
@@ -0,0 +1,3 @@
+export function getRandomId(prefix = 'id') {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
diff --git a/packages/superdoc/src/core/whiteboard/helpers/getRandomId.test.js b/packages/superdoc/src/core/whiteboard/helpers/getRandomId.test.js
new file mode 100644
index 0000000000..728cdcb792
--- /dev/null
+++ b/packages/superdoc/src/core/whiteboard/helpers/getRandomId.test.js
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { getRandomId } from './getRandomId';
+
+describe('getRandomId', () => {
+ it('returns a string', () => {
+ const id = getRandomId();
+ expect(typeof id).toBe('string');
+ });
+
+ it('includes the provided prefix', () => {
+ const id = getRandomId('text');
+ expect(id.startsWith('text-')).toBe(true);
+ });
+
+ it('uses the default prefix when none is provided', () => {
+ const id = getRandomId();
+ expect(id.startsWith('id-')).toBe(true);
+ });
+});
diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue
index d5265acc2c..2a2a19c3fb 100644
--- a/packages/superdoc/src/dev/components/SuperdocDev.vue
+++ b/packages/superdoc/src/dev/components/SuperdocDev.vue
@@ -358,7 +358,10 @@ const init = async () => {
pdfViewer: pdfjsViewer,
setWorker: true,
workerSrc: getWorkerSrcFromCDN(pdfjsLib.version),
- textLayerMode: 1,
+ textLayerMode: 0,
+ },
+ whiteboard: {
+ enabled: false,
},
},
onEditorCreate,
@@ -380,6 +383,8 @@ const init = async () => {
console.error('SuperDoc exception:', error);
});
+ window.superdoc = superdoc.value;
+
// const ydoc = superdoc.value.ydoc;
// const metaMap = ydoc.getMap('meta');
// metaMap.observe((event) => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index de8e483eb2..71697fa6cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,6 +123,9 @@ catalogs:
jszip:
specifier: 3.10.1
version: 3.10.1
+ konva:
+ specifier: ^10.2.0
+ version: 10.2.0
lefthook:
specifier: ^1.11.13
version: 1.13.6
@@ -359,6 +362,9 @@ importers:
jsdom:
specifier: 27.3.0
version: 27.3.0(canvas@3.2.0)
+ konva:
+ specifier: 'catalog:'
+ version: 10.2.0
lefthook:
specifier: 'catalog:'
version: 1.13.6
@@ -1061,6 +1067,9 @@ importers:
jsdom:
specifier: 27.3.0
version: 27.3.0(canvas@3.2.0)
+ konva:
+ specifier: 'catalog:'
+ version: 10.2.0
naive-ui:
specifier: 'catalog:'
version: 2.43.2(vue@3.5.25(typescript@5.9.3))
@@ -6799,6 +6808,9 @@ packages:
konan@2.1.1:
resolution: {integrity: sha512-7ZhYV84UzJ0PR/RJnnsMZcAbn+kLasJhVNWsu8ZyVEJYRpGA5XESQ9d/7zOa08U0Ou4cmB++hMNY/3OSV9KIbg==}
+ konva@10.2.0:
+ resolution: {integrity: sha512-JBoz0Xjbf49UPxCZegZ4WseqOzJ+C4AUDOtJ9eBve5RS5Fcq/u8TdBD5fDl/uPFInpC3a9uycm0sRyZpF4hheg==}
+
lcm@0.0.3:
resolution: {integrity: sha512-TB+ZjoillV6B26Vspf9l2L/vKaRY/4ep3hahcyVkCGFgsTNRUQdc24bQeNFiZeoxH0vr5+7SfNRMQuPHv/1IrQ==}
@@ -17495,6 +17507,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ konva@10.2.0: {}
+
lcm@0.0.3:
dependencies:
gcd: 0.0.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e0eedf053b..509d3fdef9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -48,6 +48,7 @@ catalog:
jimp: ^1.6.0
jsdom: ^27.3.0
jszip: 3.10.1
+ konva: ^10.2.0
lefthook: ^1.11.13
lib0: ^0.2.114
marked: ^16.2.0