Compare commits

...
36 Commits
Author SHA1 Message Date
Laurent Cozic 0b7667c773 update 2026-05-10 14:29:22 +01:00
Laurent Cozic c980c7131f update 2026-05-10 14:14:09 +01:00
Laurent Cozic 4200e72208 update 2026-05-10 13:03:10 +01:00
Laurent Cozic 435ffe6d8a update 2026-05-10 12:35:39 +01:00
Laurent Cozic 7d9d7aa082 Localise the default text-card content 2026-05-10 12:23:32 +01:00
Laurent Cozic b51ab1bacb Pass user_updated_time and changeSource on Note.save to close the remaining write window 2026-05-10 12:22:51 +01:00
Laurent Cozic 003ab6aaea Round-trip serialisation of edges introduces spurious onChange on load 2026-05-10 12:20:50 +01:00
Laurent Cozic 5d5c9cb245 External content reload does not update lastSerializedRef, so the next debounce can re-emit the same body 2026-05-10 12:17:49 +01:00
Laurent Cozic 9d345256c0 update 2026-05-10 12:15:03 +01:00
Laurent Cozic 076c6a9bd4 update 2026-05-10 11:44:56 +01:00
Laurent Cozic 48e67e23b0 Extract createNoteInActiveFolder helper
newWhiteboard duplicated newNote's folder lookup, preview defaults, order
computation, save and dispatches. Pull the shared logic into one helper
exported from newNote.ts and route both commands through it. The whiteboard
opts out of the geolocation update since it isn't a place-stamped capture.
2026-05-10 11:33:02 +01:00
Laurent Cozic 5efb74daf0 Short-circuit hasWhiteboardFence with literal substring check
The fence regex starts with [\s\S]*? lazy matching, which is O(n) on
strings without a fence. hasWhiteboardFence runs on every render of the
note editor (per-keystroke during typing), so we add a cheap indexOf()
precheck for the literal opening marker before falling through to the
regex.
2026-05-10 11:30:55 +01:00
Laurent Cozic 45bdb581d8 Remove unreliable noteIsWhiteboard whenClause
It was computed on every redux dispatch but had no consumers — both
whiteboard commands explicitly avoided it because `selectedNote.body` isn't
in the note preview fields, so the regex always ran on an empty body and
the result was always false. activeNoteIsWhiteboard (set by NoteEditor when
it detects a fence) remains the canonical source.
2026-05-10 11:28:27 +01:00
Laurent Cozic b9861fc13c Pending debounced save dropped on unmount
Switching notes within the 400ms debounce window silently dropped the
pending edit because the schedule effects cleanup ran clearTimeout on
every canvas change. Split scheduling from unmount cleanup, and route
content() through a synchronous flush so external readers (the form-note
save flow) always see the post-debounce body.
2026-05-10 11:24:53 +01:00
Laurent Cozic d4da382345 Untrack accidentally committed bundled CSS
The bundler output `main-html.bundle.css` was tracked in git despite being
a build artefact — sibling `.bundle.js`, `.bundle.js.map`, and `.bundle.css.map`
files were correctly ignored, only the `.css` slipped through. Nothing
references it (index.html only loads main-html.bundle.js, and the React Flow
stylesheet is read from node_modules at runtime).
2026-05-10 11:22:05 +01:00
Laurent Cozic be8f2e35ee Dark mode 2026-05-10 11:19:32 +01:00
Laurent Cozic 8ef62586ce Rapid checkbox toggles can silently drop edits 2026-05-09 18:07:11 +01:00
Laurent Cozic ede0270ce3 Refactor 2026-05-09 18:02:53 +01:00
Laurent Cozic 6f55b47fba Recompute checkbox index at click time to avoid stale task mapping 2026-05-09 17:46:45 +01:00
Laurent Cozic db6e2afb07 Non-internal image/PDF detection never reaches the preview path 2026-05-09 17:42:16 +01:00
Laurent Cozic 3173dbd9fd Reset resolved immediately when the target ref changes 2026-05-09 17:40:35 +01:00
Laurent Cozic 43a64aa143 added tests 2026-05-09 17:31:21 +01:00
Laurent Cozic 94c0c83a3d Use proper file-path-to-URL conversion in resourceUrlFor 2026-05-09 17:25:18 +01:00
Laurent Cozic 9234de7bac Avoid exposing whiteboard.enabled on mobile until mobile whiteboard is supported 2026-05-09 17:12:57 +01:00
Laurent Cozic 498eb8a5cc Race condition: stale body overwrites edits made during the picker 2026-05-09 17:09:26 +01:00
Laurent Cozic 8b33f39d28 Localise the action-panel strings 2026-05-09 17:07:31 +01:00
Laurent Cozic 9220f6b3bd onOpenRef silently swallows non-http(s) / non-internal URLs 2026-05-09 17:03:29 +01:00
Laurent Cozic 789c76d888 Image/PDF previews are broken for internal resource references 2026-05-09 17:01:43 +01:00
Laurent Cozic 90115dc8ba Restore visible keyboard focus for the input 2026-05-09 16:57:21 +01:00
Laurent Cozic b11d2b7e6e Guard WHITEBOARD_FORCE_MARKDOWN_TOGGLE against missing noteId. 2026-05-09 16:54:42 +01:00
Laurent Cozic 344428d7cf Potential race condition in onPromoteTextNode 2026-05-09 16:54:25 +01:00
Laurent Cozic 9f185b4e08 Localize user-facing strings 2026-05-09 16:51:39 +01:00
Laurent Cozic 476800ebf9 Address concurrent edit and read-only risks in checkbox save path 2026-05-09 16:49:38 +01:00
Laurent Cozic 4b1f0f8314 update 2026-05-09 16:26:43 +01:00
Laurent Cozic d68e7e839c update 2026-05-09 15:33:23 +01:00
Laurent Cozic 14f28767a2 init 2026-05-09 15:29:28 +01:00
40 changed files with 2747 additions and 49 deletions

No files matched your search

+23
View File
@@ -302,6 +302,20 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/ActionPanel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardContext.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/canvasFlow.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/injectStyle.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/loadReactFlowCss.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/FileNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/sharedStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/theme.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
@@ -479,6 +493,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
@@ -498,6 +513,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -531,6 +547,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEditor.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
@@ -1760,6 +1777,12 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
packages/lib/services/trash/permanentlyDeleteOldItems.js
packages/lib/services/trash/restoreItems.test.js
packages/lib/services/trash/restoreItems.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
packages/lib/services/whiteboard/resolveRef.js
packages/lib/services/whiteboard/serialize.js
packages/lib/services/whiteboard/whiteboard.test.js
packages/lib/shim-init-node.test.js
packages/lib/shim-init-node.js
packages/lib/shim.js
+23
View File
@@ -279,6 +279,20 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/ActionPanel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardContext.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/canvasFlow.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/injectStyle.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/loadReactFlowCss.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/FileNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/sharedStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/theme.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
@@ -456,6 +470,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
@@ -475,6 +490,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -508,6 +524,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEditor.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
@@ -1737,6 +1754,12 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
packages/lib/services/trash/permanentlyDeleteOldItems.js
packages/lib/services/trash/restoreItems.test.js
packages/lib/services/trash/restoreItems.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
packages/lib/services/whiteboard/resolveRef.js
packages/lib/services/whiteboard/serialize.js
packages/lib/services/whiteboard/whiteboard.test.js
packages/lib/shim-init-node.test.js
packages/lib/shim-init-node.js
packages/lib/shim.js
+1
View File
@@ -29,3 +29,4 @@ downloads/
# Bundler output
*.js.meta.json
*.bundle.js
*.bundle.css
+30
View File
@@ -40,6 +40,14 @@ export interface AppWindowState extends WindowState {
devToolsVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watchedResources: any;
// Note IDs for which the user has chosen to view the underlying Markdown
// instead of the Whiteboard editor. Per-window, in-memory only.
whiteboardForceMarkdown: Record<string, boolean>;
// Whether the currently-active note in this window contains a whiteboard
// fence. Set by the NoteEditor when it loads / saves the body, used by
// the toolbar to show the editor toggle button. (We can't compute this
// from the redux note list because `body` isn't in the preview fields.)
activeNoteIsWhiteboard: boolean;
}
interface BackgroundWindowStates {
@@ -72,6 +80,8 @@ export const createAppDefaultWindowState = (): AppWindowState => {
editorCodeView: true,
devToolsVisible: false,
watchedResources: {},
whiteboardForceMarkdown: {},
activeNoteIsWhiteboard: false,
};
};
@@ -205,6 +215,26 @@ export default function(state: AppState, action: any) {
};
break;
case 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE': {
const id: unknown = action.noteId;
// Guard against dispatchers forgetting to pass a noteId — writing
// an `undefined` key into the map would persist a junk entry.
if (typeof id !== 'string' || !id) break;
const current = !!state.whiteboardForceMarkdown?.[id];
newState = {
...state,
whiteboardForceMarkdown: { ...(state.whiteboardForceMarkdown || {}), [id]: !current },
};
break;
}
case 'WHITEBOARD_ACTIVE_NOTE_SET':
newState = {
...state,
activeNoteIsWhiteboard: !!action.value,
};
break;
case 'MAIN_LAYOUT_SET':
newState = {
+9 -5
View File
@@ -530,12 +530,16 @@ function useMenu(props: Props) {
];
// the following menu items will be available for all OS under Tools
const toolsItemsAll = [{
label: _('Note attachments...'),
click: () => {
navigateTo('Resources');
const toolsItemsAll = [
menuItemDic.newWhiteboard,
separator(),
{
label: _('Note attachments...'),
click: () => {
navigateTo('Resources');
},
},
}];
];
if (!shim.isMac()) {
toolsItems = toolsItems.concat(toolsItemsWindowsLinux);
@@ -0,0 +1,168 @@
import * as React from 'react';
import { CSSProperties, ReactNode, useMemo } from 'react';
import { useWhiteboardContext } from './WhiteboardContext';
import { whiteboardColors, WhiteboardThemeColors } from './theme';
interface Props {
// Where to anchor the panel relative to its positioned ancestor.
// 'bottom-center' is the default — used for selection-context panels.
position?: 'bottom-center' | 'top-center' | 'top-right';
// Optional caption shown at the start of the bar (e.g. "1 connection").
caption?: ReactNode;
// Buttons / inputs / dividers shown in the bar.
children?: ReactNode;
}
const positionStyles: Record<NonNullable<Props['position']>, CSSProperties> = {
'bottom-center': { bottom: 16, left: '50%', transform: 'translateX(-50%)' },
'top-center': { top: 16, left: '50%', transform: 'translateX(-50%)' },
'top-right': { top: 8, right: 8 },
};
const baseStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
position: 'absolute',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: 0,
padding: 4,
background: colors.cardBackground,
border: `1px solid ${colors.cardBorder}`,
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.10)',
fontSize: 12,
height: 36,
color: colors.textColor,
});
const captionStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
color: colors.mutedColor,
padding: '0 10px',
whiteSpace: 'nowrap',
});
const dividerStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
width: 1,
height: 20,
background: colors.dividerColor,
margin: '0 4px',
});
export const ActionPanel = ({ position = 'bottom-center', caption, children }: Props) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
const style: CSSProperties = { ...baseStyle(colors), ...positionStyles[position] };
return (
<div style={style}>
{caption ? (
<>
<div style={captionStyle(colors)}>{caption}</div>
<div style={dividerStyle(colors)} />
</>
) : null}
{children}
</div>
);
};
const buttonBase = (colors: WhiteboardThemeColors): CSSProperties => ({
height: 28,
padding: '0 10px',
fontSize: 12,
border: 'none',
background: 'transparent',
color: colors.textColor,
cursor: 'pointer',
borderRadius: 6,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
whiteSpace: 'nowrap',
});
interface ActionButtonProps {
onClick: ()=> void;
active?: boolean;
disabled?: boolean;
title?: string;
children: ReactNode;
}
export const ActionButton = ({ onClick, active, disabled, title, children }: ActionButtonProps) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
// "Active" tint is a translucent brand blue overlay so it reads on both
// light and dark backgrounds without needing a second hex value.
const activeBg = 'rgba(74, 144, 226, 0.18)';
const activeFg = '#2766b8';
const hoverBg = 'rgba(127,127,127,0.12)';
const style: CSSProperties = {
...buttonBase(colors),
background: active ? activeBg : 'transparent',
color: active ? activeFg : colors.textColor,
opacity: disabled ? 0.45 : 1,
cursor: disabled ? 'default' : 'pointer',
};
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
// Many ActionButtons render only a glyph (—, →, ↔ …) whose Unicode
// name isn't a useful accessible name. Mirror `title` into
// aria-label so screen readers and voice-control users get the
// human-readable label the tooltip already shows.
aria-label={title}
aria-pressed={active}
style={style}
onMouseEnter={e => { if (!disabled && !active) (e.currentTarget.style.background = hoverBg); }}
onMouseLeave={e => { if (!active) (e.currentTarget.style.background = 'transparent'); }}
>
{children}
</button>
);
};
export const ActionDivider = () => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
return <div style={dividerStyle(colors)} />;
};
const inputStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
height: 24,
padding: '0 8px',
fontSize: 12,
border: `1px solid ${colors.cardBorder}`,
borderRadius: 4,
margin: '0 4px',
background: colors.cardBackground,
color: colors.textColor,
// Keep the browser's default focus ring for keyboard accessibility — do
// not set `outline: 'none'`.
});
interface ActionInputProps {
value: string;
placeholder?: string;
width?: number;
onChange: (value: string)=> void;
}
export const ActionInput = ({ value, placeholder, width = 140, onChange }: ActionInputProps) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
return (
<input
type="text"
value={value}
placeholder={placeholder}
// Without a visible <label> the placeholder is the only label a
// screen reader / voice-control user has to identify the field.
aria-label={placeholder}
onChange={e => onChange(e.target.value)}
style={{ ...inputStyle(colors), width }}
/>
);
};
@@ -0,0 +1,23 @@
import { createContext, useContext } from 'react';
import { MarkupLanguage } from '@joplin/renderer';
import { ResourceInfos } from '../../utils/types';
import { MarkupToHtmlOptions } from '../../../hooks/useMarkupToHtml';
import { RenderResult } from '@joplin/renderer/types';
export interface WhiteboardContextValue {
markupToHtml: (markupLanguage: MarkupLanguage, md: string, options?: MarkupToHtmlOptions)=> Promise<RenderResult>;
resourceInfos: ResourceInfos;
resourceDirectory: string;
themeId: number;
onOpenRef: (ref: string)=> void;
onUpdateNode: (canvasNodeId: string, patch: Record<string, unknown>)=> void;
onPromoteTextNode: (canvasNodeId: string)=> void;
}
export const WhiteboardContext = createContext<WhiteboardContextValue | null>(null);
export const useWhiteboardContext = () => {
const ctx = useContext(WhiteboardContext);
if (!ctx) throw new Error('WhiteboardContext used outside provider');
return ctx;
};
@@ -0,0 +1,197 @@
import * as React from 'react';
import { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { NoteBodyEditorProps, NoteBodyEditorRef } from '../../utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import Note from '@joplin/lib/models/Note';
import { Canvas, CanvasNode, FileCanvasNode, TextCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { parseWhiteboard } from '@joplin/lib/services/whiteboard/parse';
import { serializeWhiteboard } from '@joplin/lib/services/whiteboard/serialize';
import { themeStyle } from '@joplin/lib/theme';
import { WhiteboardContext } from './WhiteboardContext';
import WhiteboardSurface from './WhiteboardSurface';
const SAVE_DEBOUNCE_MS = 400;
const WhiteboardEditor = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
const bodyRef = useRef(props.content);
bodyRef.current = props.content;
const initialParse = useMemo(() => parseWhiteboard(props.content), [props.content]);
const initialCanvas = initialParse.canvas;
const parseError = initialParse.parseError;
const [canvas, setCanvas] = useState<Canvas>(initialCanvas);
// Mirror the canvas state in a ref so async handlers can read the latest
// version without going through a setCanvas updater (which must stay pure).
const canvasRef = useRef<Canvas>(initialCanvas);
canvasRef.current = canvas;
// Debounced save. We split "schedule" from "unmount" cleanup because the
// effect's normal cleanup runs whenever `canvas` changes — that's a
// re-schedule, not a reason to drop the pending save. Only an actual
// unmount (or an explicit caller via `content()`) should flush.
const lastSerializedRef = useRef<string>(JSON.stringify(canvas));
const pendingSerializedRef = useRef<string | null>(null);
const pendingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onChangeRef = useRef(props.onChange);
onChangeRef.current = props.onChange;
// Reload when the body switches to a different note, or when the body has
// changed underneath us (external write — e.g. the "add note to whiteboard"
// command — which produces a body we didn't emit).
const lastEmittedBodyRef = useRef<string>(props.content);
useEffect(() => {
if (props.content === lastEmittedBodyRef.current) return;
lastEmittedBodyRef.current = props.content;
const parsed = parseWhiteboard(props.content);
setCanvas(parsed.canvas);
// Mark the freshly-loaded canvas as already-synced so the debounced
// save effect doesn't echo it straight back as a write.
lastSerializedRef.current = JSON.stringify(parsed.canvas);
}, [props.content, props.contentKey]);
const flushPendingSave = useCallback((): string => {
if (pendingTimeoutRef.current !== null) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
const serialized = pendingSerializedRef.current;
if (serialized === null) return bodyRef.current;
pendingSerializedRef.current = null;
lastSerializedRef.current = serialized;
const newBody = serializeWhiteboard(bodyRef.current, JSON.parse(serialized) as Canvas);
bodyRef.current = newBody;
lastEmittedBodyRef.current = newBody;
onChangeRef.current({ changeId: null, content: newBody });
return newBody;
}, []);
useEffect(() => {
// Never write back when the source body had an unparseable fence —
// otherwise opening a corrupt note would silently overwrite the
// user's recoverable JSON with an empty canvas.
if (parseError) return undefined;
const serialized = JSON.stringify(canvas);
if (serialized === lastSerializedRef.current) return undefined;
pendingSerializedRef.current = serialized;
// Replace any prior pending timeout — we'll re-schedule from the new
// canvas. Crucially we do NOT clear the pending serialised payload
// here, so the unmount-flush effect can still see it.
if (pendingTimeoutRef.current !== null) clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = setTimeout(() => {
pendingTimeoutRef.current = null;
flushPendingSave();
}, SAVE_DEBOUNCE_MS);
return undefined;
}, [canvas, flushPendingSave, parseError]);
// Flush on unmount. The empty-deps effect's cleanup only fires once.
useEffect(() => {
return () => { flushPendingSave(); };
}, [flushPendingSave]);
useImperativeHandle(ref, () => ({
// Callers that read `content()` (e.g. the form-note save flow) get
// the latest body even if a debounced save is still pending.
content: () => flushPendingSave(),
resetScroll: () => { /* not applicable */ },
scrollTo: () => { /* not applicable */ },
supportsCommand: () => false,
execCommand: async () => { /* not applicable */ },
}), [flushPendingSave]);
const onUpdateNode = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvas(prev => ({
...prev,
nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, ...patch } as CanvasNode : n),
}));
}, []);
const onOpenRef = useCallback((value: string) => {
if (!value) return;
// `openItem` already handles every supported link form: `:/id`,
// `joplin://`, `file://`, any other URL scheme (http/https/mailto/
// ftp/...), and shows a user-facing error for unsupported strings.
void CommandService.instance().execute('openItem', value);
}, []);
// Promote a text card to a real Joplin note: create a note in the same
// folder as the whiteboard, with the card's text as body and its first
// non-empty line as title; replace the text node with a file-ref node
// pointing at the new note.
const onPromoteTextNode = useCallback(async (canvasNodeId: string) => {
const noteId = props.noteId;
if (!noteId) return;
// Read the latest canvas state directly — never inside a setCanvas
// updater, since updaters must stay pure (React 18 strict mode runs
// them twice in dev, which would create the note twice).
const node = canvasRef.current.nodes.find(n => n.id === canvasNodeId) as TextCanvasNode | undefined;
if (!node || node.type !== 'text') return;
const parentNote = await Note.load(noteId);
if (!parentNote) return;
const title = (node.text.split('\n').find(l => l.trim().length) || '').replace(/^#+\s*/, '').trim() || 'Untitled';
const created = await Note.save({
parent_id: parentNote.parent_id,
title,
body: node.text,
});
// Re-locate the node from the latest state in case it moved/resized
// between the promote click and the save returning. If it was deleted
// in the meantime, drop the operation silently.
const latest = canvasRef.current.nodes.find(n => n.id === canvasNodeId) as TextCanvasNode | undefined;
if (!latest || latest.type !== 'text') return;
const replacement: FileCanvasNode = {
id: latest.id,
type: 'file',
x: latest.x,
y: latest.y,
width: latest.width,
height: latest.height,
file: `:/${created.id}`,
};
setCanvas(curr => ({
...curr,
nodes: curr.nodes.map(n => n.id === latest.id ? replacement : n),
}));
}, [props.noteId]);
const contextValue = useMemo(() => ({
markupToHtml: props.markupToHtml,
resourceInfos: props.resourceInfos,
resourceDirectory: props.resourceDirectory,
themeId: props.themeId,
onOpenRef,
onUpdateNode,
onPromoteTextNode,
}), [props.markupToHtml, props.resourceInfos, props.resourceDirectory, props.themeId, onOpenRef, onUpdateNode, onPromoteTextNode]);
if (parseError) {
const theme = themeStyle(props.themeId);
return (
<div style={{ ...props.style, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, padding: 24, overflow: 'auto' }}>
<div style={{ backgroundColor: theme.warningBackgroundColor, color: theme.color, padding: 16, borderRadius: 4, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontWeight: 600 }}>This whiteboard could not be loaded</div>
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>{parseError}</div>
<div>Click the eye icon in the toolbar to switch to the Markdown editor and fix the JSON manually.</div>
</div>
</div>
);
}
return (
<div style={{ ...props.style, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<WhiteboardContext.Provider value={contextValue}>
<WhiteboardSurface
canvas={canvas}
onChange={setCanvas}
/>
</WhiteboardContext.Provider>
</div>
);
};
export default forwardRef(WhiteboardEditor);
@@ -0,0 +1,340 @@
import * as React from 'react';
import { CSSProperties, DragEvent as ReactDragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Background,
Connection,
ConnectionMode,
Controls,
Edge,
MarkerType,
MiniMap,
Node,
NodeTypes,
OnConnect,
OnEdgesChange,
OnNodesChange,
ReactFlow,
ReactFlowProvider,
applyEdgeChanges,
applyNodeChanges,
useReactFlow,
} from '@xyflow/react';
import ensureReactFlowCss, { applyReactFlowTheme } from './loadReactFlowCss';
import generateId from '@joplin/lib/services/whiteboard/generateId';
import { _, _n } from '@joplin/lib/locale';
import { Canvas, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
ensureReactFlowCss();
import { canvasNodeToFlowNode, canvasToFlow, flowToCanvas, WhiteboardFlowEdge, WhiteboardFlowNode } from './canvasFlow';
import TextNode from './nodes/TextNode';
import FileNode from './nodes/FileNode';
import LinkNode from './nodes/LinkNode';
import { ActionButton, ActionDivider, ActionInput, ActionPanel } from './ActionPanel';
import { useWhiteboardContext } from './WhiteboardContext';
import { whiteboardColors } from './theme';
// `markerUnits: 'userSpaceOnUse'` keeps the arrowhead at an absolute size,
// independent of the edge's stroke width. Without it, selected edges (which
// have a thicker stroke) would render a proportionally bigger arrow.
const makeArrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
type ArrowMode = 'none' | 'forward' | 'backward' | 'both' | 'mixed';
const arrowModeFor = (e: WhiteboardFlowEdge): Exclude<ArrowMode, 'mixed'> => {
const start = !!e.markerStart;
const end = !!e.markerEnd;
if (start && end) return 'both';
if (end) return 'forward';
if (start) return 'backward';
return 'none';
};
interface Props {
canvas: Canvas;
onChange: (canvas: Canvas)=> void;
}
const nodeTypes: NodeTypes = {
wbText: TextNode as unknown as NodeTypes[string],
wbFile: FileNode as unknown as NodeTypes[string],
wbLink: LinkNode as unknown as NodeTypes[string],
};
const InnerSurface = ({ canvas, onChange }: Props) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
// Re-apply React Flow's CSS custom properties whenever the theme changes
// so edges, minimap, controls and dot grid follow the active Joplin theme.
useEffect(() => {
applyReactFlowTheme(colors);
}, [colors]);
const containerStyle: CSSProperties = useMemo(() => ({
position: 'relative',
flex: 1,
width: '100%',
height: '100%',
background: colors.surfaceBackground,
outline: 'none',
}), [colors]);
const initial = useMemo(() => canvasToFlow(canvas), [canvas]);
const [flowNodes, setFlowNodes] = useState<WhiteboardFlowNode[]>(initial.nodes);
const [flowEdges, setFlowEdges] = useState<WhiteboardFlowEdge[]>(initial.edges);
// JSONCanvas group nodes are not rendered, but we preserve them through
// round-trip so importing a canvas from another tool and
// re-saving doesn't silently drop them.
const preservedGroupsRef = useRef<CanvasNode[]>(initial.preservedGroups);
const containerRef = useRef<HTMLDivElement | null>(null);
const rf = useReactFlow();
// When the incoming canvas changes (note loaded externally), reload state
// — but skip if it's the same canvas we just emitted (avoid feedback loops).
// Seed with the *round-tripped* serialization so optional edge fields
// (fromEnd/toEnd) added by flowToCanvas don't make the very first push-back
// effect see the canvas as "different" and emit a spurious onChange.
const lastEmittedRef = useRef<string>(JSON.stringify(flowToCanvas(initial.nodes, initial.edges, initial.preservedGroups)));
useEffect(() => {
const incoming = JSON.stringify(canvas);
if (incoming === lastEmittedRef.current) return;
const next = canvasToFlow(canvas);
setFlowNodes(next.nodes);
setFlowEdges(next.edges);
preservedGroupsRef.current = next.preservedGroups;
// Stamp with the round-tripped form too, for the same reason as the
// initial seed above.
lastEmittedRef.current = JSON.stringify(flowToCanvas(next.nodes, next.edges, next.preservedGroups));
}, [canvas]);
// Push changes back to the parent whenever the local flow state changes.
useEffect(() => {
const out = flowToCanvas(flowNodes, flowEdges, preservedGroupsRef.current);
const serialized = JSON.stringify(out);
if (serialized === lastEmittedRef.current) return;
lastEmittedRef.current = serialized;
onChange(out);
}, [flowNodes, flowEdges, onChange]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
setFlowNodes(prev => applyNodeChanges(changes, prev) as WhiteboardFlowNode[]);
}, []);
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
setFlowEdges(prev => applyEdgeChanges(changes, prev) as WhiteboardFlowEdge[]);
}, []);
const onConnect: OnConnect = useCallback((connection: Connection) => {
const edge: WhiteboardFlowEdge = {
id: generateId(),
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
markerEnd: makeArrowMarker(),
data: {
canvasEdge: {
id: '',
fromNode: connection.source,
toNode: connection.target,
} as CanvasEdge,
},
};
setFlowEdges(prev => [...prev, edge]);
}, []);
const defaultEdgeOptions = useMemo(() => ({
markerEnd: makeArrowMarker(),
}), []);
// Append a freshly-created canvas node to the surface's local flow state.
// The render-cycle effect at flowToCanvas → onChange propagates the new
// node up to the parent, so we don't need an explicit onAddNode prop.
const addCanvasNode = useCallback((n: Exclude<CanvasNode, { type: 'group' }>) => {
setFlowNodes(prev => [...prev, canvasNodeToFlowNode(n)]);
}, []);
const onAddText = useCallback(() => {
const view = rf.getViewport();
const rect = containerRef.current?.getBoundingClientRect();
const cx = rect ? (rect.width / 2 - view.x) / view.zoom : 0;
const cy = rect ? (rect.height / 2 - view.y) / view.zoom : 0;
addCanvasNode({
id: generateId(),
type: 'text',
x: cx - 100,
y: cy - 50,
width: 200,
height: 100,
text: _('New text card'),
});
}, [rf, addCanvasNode]);
// Selection summaries for the action panels.
const selectedEdges = useMemo(() => flowEdges.filter(e => e.selected), [flowEdges]);
const selectedNodes = useMemo(() => flowNodes.filter(n => n.selected), [flowNodes]);
// Edges fed to React Flow. For selected edges we override marker colour
// to match the selection blue — markers are SVG <marker> defs that don't
// inherit stroke colour from the edge path automatically.
const SELECTED_EDGE_COLOR = '#4a90e2';
const renderedEdges = useMemo<WhiteboardFlowEdge[]>(() => {
return flowEdges.map(e => {
if (!e.selected) return e;
const tint = (m: WhiteboardFlowEdge['markerEnd']) =>
(m && typeof m === 'object') ? { ...m, color: SELECTED_EDGE_COLOR } : m;
return { ...e, markerEnd: tint(e.markerEnd), markerStart: tint(e.markerStart) };
});
}, [flowEdges]);
const updateSelectedEdges = useCallback((patch: (e: WhiteboardFlowEdge)=> WhiteboardFlowEdge) => {
setFlowEdges(prev => prev.map(e => e.selected ? patch(e) : e));
}, []);
const currentArrowMode: ArrowMode = useMemo(() => {
if (!selectedEdges.length) return 'none';
const first = arrowModeFor(selectedEdges[0]);
for (const e of selectedEdges) if (arrowModeFor(e) !== first) return 'mixed';
return first;
}, [selectedEdges]);
const setArrowMode = useCallback((mode: Exclude<ArrowMode, 'mixed'>) => {
updateSelectedEdges(e => ({
...e,
markerStart: (mode === 'backward' || mode === 'both') ? makeArrowMarker() : undefined,
markerEnd: (mode === 'forward' || mode === 'both') ? makeArrowMarker() : undefined,
}));
}, [updateSelectedEdges]);
const flipDirection = useCallback(() => {
updateSelectedEdges(e => ({
...e,
source: e.target,
target: e.source,
sourceHandle: e.targetHandle,
targetHandle: e.sourceHandle,
markerStart: e.markerEnd,
markerEnd: e.markerStart,
}));
}, [updateSelectedEdges]);
const setEdgeLabel = useCallback((label: string) => {
updateSelectedEdges(e => ({ ...e, label }));
}, [updateSelectedEdges]);
const onDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const types = Array.from(e.dataTransfer.types);
if (types.includes('text/x-jop-note-ids') || types.includes('text/x-jop-resource-ids')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
}
}, []);
const onDrop = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const noteIdsRaw = e.dataTransfer.getData('text/x-jop-note-ids');
const resourceIdsRaw = e.dataTransfer.getData('text/x-jop-resource-ids');
if (!noteIdsRaw && !resourceIdsRaw) return;
e.preventDefault();
e.stopPropagation();
const drop = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });
const tryParse = (raw: string): string[] => {
if (!raw) return [];
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : []; } catch { return []; }
};
const ids = [
...tryParse(noteIdsRaw),
...tryParse(resourceIdsRaw),
];
let offset = 0;
for (const id of ids) {
addCanvasNode({
id: generateId(),
type: 'file',
x: drop.x - 120 + offset,
y: drop.y - 60 + offset,
width: 240,
height: 160,
file: `:/${id}`,
});
offset += 24;
}
}, [rf, addCanvasNode]);
return (
<div
ref={containerRef}
style={containerStyle}
onDragOver={onDragOver}
onDrop={onDrop}
>
<ReactFlow
nodes={flowNodes as unknown as Node[]}
edges={renderedEdges as unknown as Edge[]}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
connectionMode={ConnectionMode.Loose}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode={['Shift']}
panOnScroll
panOnDrag
zoomOnPinch
zoomOnScroll={false}
fitView={flowNodes.length > 0}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<Controls showInteractive={false} />
<MiniMap pannable zoomable style={{ width: 160, height: 100 }} />
<ActionPanel position="top-right">
<ActionButton onClick={onAddText} title={_('Add a text card')}>{_('+ Text')}</ActionButton>
</ActionPanel>
{selectedEdges.length > 0 ? (
<ActionPanel
position="bottom-center"
caption={_n('%d connection', '%d connections', selectedEdges.length, selectedEdges.length)}
>
<ActionButton onClick={() => setArrowMode('none')} active={currentArrowMode === 'none'} title={_('No arrow')}>—</ActionButton>
<ActionButton onClick={() => setArrowMode('forward')} active={currentArrowMode === 'forward'} title={_('Arrow at target')}>→</ActionButton>
<ActionButton onClick={() => setArrowMode('backward')} active={currentArrowMode === 'backward'} title={_('Arrow at source')}>←</ActionButton>
<ActionButton onClick={() => setArrowMode('both')} active={currentArrowMode === 'both'} title={_('Bidirectional')}>↔</ActionButton>
<ActionDivider />
<ActionButton onClick={flipDirection} title={_('Swap source and target')}>{_('Flip')}</ActionButton>
{selectedEdges.length === 1 ? (
<>
<ActionDivider />
<ActionInput
value={typeof selectedEdges[0].label === 'string' ? selectedEdges[0].label : ''}
placeholder={_('Label')}
onChange={setEdgeLabel}
/>
</>
) : null}
</ActionPanel>
) : null}
{selectedNodes.length > 0 && selectedEdges.length === 0 ? (
<ActionPanel
position="bottom-center"
caption={_n('%d card', '%d cards', selectedNodes.length, selectedNodes.length)}
>
{/* Per-card actions can be added here later (colour, alignment, etc.). */}
</ActionPanel>
) : null}
</ReactFlow>
</div>
);
};
const WhiteboardSurface = (props: Props) => (
<ReactFlowProvider>
<InnerSurface {...props} />
</ReactFlowProvider>
);
export default WhiteboardSurface;
@@ -0,0 +1,137 @@
// Translation between the JSONCanvas spec shape and React Flow's
// node/edge shape. Pure functions, no React imports.
import { Edge as FlowEdge, MarkerType, Node as FlowNode } from '@xyflow/react';
import { Canvas, CanvasEdge, CanvasNode, CanvasNodeSide } from '@joplin/lib/services/whiteboard/jsoncanvas';
export type WhiteboardNodeData = {
canvasNode: CanvasNode;
};
export type WhiteboardFlowNode = FlowNode<WhiteboardNodeData>;
export type WhiteboardFlowEdge = FlowEdge<{ canvasEdge: CanvasEdge }>;
// Maps JSONCanvas node types to our React Flow node types. Group nodes are
// not rendered, so they're filtered out before this is called.
const flowTypeForCanvasType = (type: 'text' | 'file' | 'link'): string => {
switch (type) {
case 'text': return 'wbText';
case 'file': return 'wbFile';
case 'link': return 'wbLink';
}
};
// Convert a single (non-group) JSONCanvas node into the React Flow shape.
// Used both by the bulk converter at load time and by the surface when the
// user creates a new node interactively.
export const canvasNodeToFlowNode = (n: Exclude<CanvasNode, { type: 'group' }>): WhiteboardFlowNode => ({
id: n.id,
type: flowTypeForCanvasType(n.type),
position: { x: n.x, y: n.y },
data: { canvasNode: n },
style: { width: n.width, height: n.height },
width: n.width,
height: n.height,
selectable: true,
draggable: true,
});
const sideToHandle = (side: CanvasNodeSide | undefined): string | undefined => {
if (!side) return undefined;
return side; // React Flow handle ids match side names ('top', 'right', 'bottom', 'left').
};
// `markerUnits: 'userSpaceOnUse'` keeps the arrowhead at an absolute size,
// independent of the edge's stroke width. Without it, selected edges (which
// have a thicker stroke) would render a proportionally bigger arrow.
const arrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
export interface CanvasToFlowResult {
nodes: WhiteboardFlowNode[];
edges: WhiteboardFlowEdge[];
// JSONCanvas group nodes are not rendered by this editor, but we keep them
// here so they can be merged back on save and round-trip cleanly.
preservedGroups: CanvasNode[];
}
export const canvasToFlow = (canvas: Canvas): CanvasToFlowResult => {
const preservedGroups: CanvasNode[] = [];
const nodes: WhiteboardFlowNode[] = [];
for (const n of canvas.nodes) {
if (n.type === 'group') {
preservedGroups.push(n);
continue;
}
nodes.push(canvasNodeToFlowNode(n));
}
const edges: WhiteboardFlowEdge[] = canvas.edges.map(e => ({
id: e.id,
source: e.fromNode,
target: e.toNode,
sourceHandle: sideToHandle(e.fromSide),
targetHandle: sideToHandle(e.toSide),
label: e.label,
data: { canvasEdge: e },
type: 'default',
markerStart: e.fromEnd === 'arrow' ? arrowMarker() : undefined,
markerEnd: e.toEnd === 'none' ? undefined : arrowMarker(),
}));
return { nodes, edges, preservedGroups };
};
const handleToSide = (handle?: string | null): CanvasNodeSide | undefined => {
if (handle === 'top' || handle === 'right' || handle === 'bottom' || handle === 'left') return handle;
return undefined;
};
// Apply React Flow positions back to canvas nodes. Preserves any field we
// don't track (color, subpath, etc.) by spreading the original canvasNode
// from `data`. Group nodes that were filtered out at load time are merged
// back unchanged via `preservedGroups`.
export const flowToCanvas = (
flowNodes: WhiteboardFlowNode[],
flowEdges: WhiteboardFlowEdge[],
preservedGroups: CanvasNode[] = [],
): Canvas => {
const nodes: CanvasNode[] = flowNodes.map(fn => {
const orig = fn.data?.canvasNode;
const width = (typeof fn.width === 'number' ? fn.width : (typeof fn.style?.width === 'number' ? fn.style.width : orig?.width)) ?? 200;
const height = (typeof fn.height === 'number' ? fn.height : (typeof fn.style?.height === 'number' ? fn.style.height : orig?.height)) ?? 100;
const base = {
id: fn.id,
x: fn.position.x,
y: fn.position.y,
width,
height,
};
if (!orig) {
return { ...base, type: 'text', text: '' };
}
return { ...orig, ...base };
});
// Re-attach preserved group nodes so a round-trip doesn't drop them.
for (const g of preservedGroups) nodes.push(g);
const edges: CanvasEdge[] = flowEdges.map(fe => {
const orig = fe.data?.canvasEdge;
const fromEnd: CanvasEdge['fromEnd'] = fe.markerStart ? 'arrow' : 'none';
const toEnd: CanvasEdge['toEnd'] = fe.markerEnd ? 'arrow' : 'none';
return {
id: fe.id,
fromNode: fe.source,
toNode: fe.target,
fromSide: handleToSide(fe.sourceHandle),
toSide: handleToSide(fe.targetHandle),
label: typeof fe.label === 'string' ? fe.label : (orig?.label),
...(orig?.color ? { color: orig.color } : {}),
fromEnd,
toEnd,
};
});
return { nodes, edges };
};
@@ -0,0 +1,28 @@
// Joplin's desktop build doesn't run CSS imports through a loader, so we
// inject style sheets at runtime as <style> tags.
//
// - `injectStyle(id, css)` is a one-shot, idempotent injection.
// - `replaceStyle(id, css)` updates an existing <style> in place — for
// theme-dependent CSS that needs to change when the theme changes.
const injectStyle = (id: string, css: string) => {
if (typeof document === 'undefined') return;
if (document.getElementById(id)) return;
const el = document.createElement('style');
el.id = id;
el.textContent = css;
document.head.appendChild(el);
};
export const replaceStyle = (id: string, css: string) => {
if (typeof document === 'undefined') return;
let el = document.getElementById(id) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = id;
document.head.appendChild(el);
}
el.textContent = css;
};
export default injectStyle;
@@ -0,0 +1,92 @@
// React Flow ships CSS as a separate file. Joplin's desktop build doesn't run
// CSS imports through a loader, so we read the stylesheet at runtime and
// inject it into the document head once. We also expose a theme-aware
// override that overrides React Flow's CSS custom properties (--xy-*) so
// edges, minimap and dot grid follow the active Joplin theme.
import injectStyle, { replaceStyle } from './injectStyle';
import { SELECTION_COLOR, WhiteboardThemeColors } from './theme';
const STYLE_ELEMENT_ID = 'whiteboard-react-flow-css';
const THEME_STYLE_ELEMENT_ID = 'whiteboard-react-flow-theme';
let injected = false;
const ensureReactFlowCss = () => {
if (injected) return;
if (typeof document === 'undefined') return;
try {
// require() at runtime so this resolves through Node, which is fine in
// Electron's renderer process.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkgPath = require.resolve('@xyflow/react/package.json');
const cssPath = path.join(path.dirname(pkgPath), 'dist', 'style.css');
const baseCss = fs.readFileSync(cssPath, 'utf8');
// React Flow base styles, then our overrides. Selected edges should
// stand out as clearly as selected cards, and connection handles are
// hidden until hover/selection so the canvas isn't littered with dots.
const overrides = `
.react-flow__edge.selected .react-flow__edge-path,
.react-flow__edge:focus .react-flow__edge-path,
.react-flow__edge:focus-visible .react-flow__edge-path {
stroke: ${SELECTION_COLOR} !important;
stroke-width: 2 !important;
}
.react-flow__edge.selected .react-flow__edge-textbg {
fill: ${SELECTION_COLOR};
}
.react-flow__edge.selected .react-flow__edge-text {
fill: #ffffff;
}
.react-flow__node .react-flow__handle {
opacity: 0;
transition: opacity 120ms ease;
}
.react-flow__node:hover .react-flow__handle,
.react-flow__node.selected .react-flow__handle,
.react-flow__handle.connectingfrom,
.react-flow__handle.connectingto {
opacity: 1;
}
`;
injectStyle(STYLE_ELEMENT_ID, `${baseCss}${overrides}`);
injected = true;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load React Flow CSS', error);
}
};
// Apply the active Joplin theme to React Flow by setting its `--xy-*` CSS
// custom properties at the `.react-flow` root. Re-injected on every theme
// change so dark mode actually looks dark. Scoped via a class so we don't
// accidentally affect other React Flow instances if Joplin ever embeds one.
export const applyReactFlowTheme = (colors: WhiteboardThemeColors) => {
const css = `
.react-flow {
--xy-background-color-default: ${colors.surfaceBackground};
--xy-background-pattern-dots-color-default: ${colors.dividerColor};
--xy-edge-stroke-default: ${colors.dividerColor};
--xy-edge-stroke-selected-default: ${SELECTION_COLOR};
--xy-connectionline-stroke-default: ${colors.handleColor};
--xy-attribution-background-color-default: ${colors.cardBackground};
--xy-minimap-background-color-default: ${colors.cardBackground};
--xy-minimap-mask-background-color-default: ${colors.surfaceBackground};
--xy-minimap-node-background-color-default: ${colors.handleColor};
--xy-node-color-default: ${colors.textColor};
--xy-node-background-color-default: ${colors.cardBackground};
--xy-controls-button-background-color-default: ${colors.cardBackground};
--xy-controls-button-color-default: ${colors.textColor};
--xy-controls-button-border-color-default: ${colors.dividerColor};
}
`;
replaceStyle(THEME_STYLE_ELEMENT_ID, css);
};
export default ensureReactFlowCss;
@@ -0,0 +1,278 @@
import * as React from 'react';
import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { pathToFileURL } from 'url';
import { MarkupLanguage } from '@joplin/renderer';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import ItemChange from '@joplin/lib/models/ItemChange';
import { ModelType } from '@joplin/lib/BaseModel';
import attachedResources from '@joplin/lib/utils/attachedResources';
import Logger from '@joplin/utils/Logger';
import { resourceFullPath } from '@joplin/lib/models/utils/resourceUtils';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import { FileCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { isInternalRef, RefKind, resolveFileRef } from '@joplin/lib/services/whiteboard/resolveRef';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import { whiteboardColors } from '../theme';
import { bodyStyle, cardStyle, handlePositions, headerStyle } from './sharedStyles';
const logger = Logger.create('WhiteboardFileNode');
// Header showing the linked note's title — replaces the generic "NOTE" badge
// when we know the title. Truncated with ellipsis on overflow.
const noteHeaderStyle = (textColor: string, dividerColor: string): CSSProperties => ({
fontSize: 12,
fontWeight: 600,
color: textColor,
padding: '5px 8px',
borderBottom: `1px solid ${dividerColor}`,
flexShrink: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
// Build a file:// URL pointing at the resource's blob on disk. Delegates
// the path-on-disk computation to resourceFullPath so we get the same
// extension logic as the rest of Joplin (file_extension first, then a
// mime → extension fallback for resources missing an explicit extension).
// pathToFileURL handles Windows separators and special-character encoding.
const resourceUrlFor = (resource: ResourceEntity | null, resourceDirectory: string): string | null => {
if (!resource || !resourceDirectory) return null;
return pathToFileURL(resourceFullPath(resource, resourceDirectory)).href;
};
interface ResolvedItem {
kind: 'note' | 'resource' | 'unknown';
title: string;
body?: string;
// Note metadata used to gate writes from this card (e.g. checkbox
// toggling) and to enable conflict detection on save.
userUpdatedTime?: number;
deletedTime?: number;
// The full resource entity for `kind: 'resource'` items, so we can pass
// it straight to resourceFullPath / resourceFilename (which know how to
// fall back from missing file_extension to a mime-derived one).
resource?: ResourceEntity;
}
const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch: ()=> void } => {
const [resolved, setResolved] = useState<ResolvedItem | null>(null);
const [refetchCount, setRefetchCount] = useState(0);
const lastLoadedFileRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
const ref = resolveFileRef(file);
if (ref.kind === RefKind.External) {
setResolved(null);
lastLoadedFileRef.current = null;
return undefined;
}
// Clear any previously-resolved item before loading when the ref has
// changed, so switching from one internal ref to another doesn't show
// stale content during the async load. Skip the clear on a refetch
// of the same ref (e.g. after a checkbox toggle saves the note) —
// otherwise the preview would flicker on every refetch.
if (lastLoadedFileRef.current !== file) {
setResolved(null);
lastLoadedFileRef.current = file;
}
void (async () => {
try {
const item = await BaseItem.loadItemById(ref.id);
if (cancelled) return;
if (!item) {
setResolved({ kind: 'unknown', title: file });
return;
}
if (item.type_ === ModelType.Note) {
setResolved({
kind: 'note',
title: item.title || 'Untitled',
body: item.body || '',
userUpdatedTime: item.user_updated_time,
deletedTime: item.deleted_time,
});
} else if (item.type_ === ModelType.Resource) {
setResolved({
kind: 'resource',
title: item.title || file,
resource: item as ResourceEntity,
});
} else {
setResolved({ kind: 'unknown', title: file });
}
} catch {
if (!cancelled) setResolved({ kind: 'unknown', title: file });
}
})();
return () => { cancelled = true; };
}, [file, refetchCount]);
return { resolved, refetch: () => setRefetchCount(c => c + 1) };
};
const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
const ctx = useWhiteboardContext();
const node = data.canvasNode as FileCanvasNode;
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
ctx.onOpenRef(node.file);
}, [ctx, node.file]);
const { resolved, refetch } = useResolvedRef(node.file);
// Internal refs go through the resolved resource (which carries mime +
// file_extension from the database). External refs may already be URLs
// (http/https/file), in which case we use them as-is for rendering;
// bare paths from other tools can't be resolved here so we leave url null and fall
// back to the text branch.
const isInternal = isInternalRef(node.file);
const url = isInternal
? resourceUrlFor(resolved?.resource ?? null, ctx.resourceDirectory)
: (/^(https?:|file:)\/\//i.test(node.file) ? node.file : null);
const mime = resolved?.resource?.mime;
const isPdf = isInternal
? mime === 'application/pdf'
: /\.pdf(\?|$|#)/i.test(node.file);
const isImage = isInternal
? !!mime?.startsWith('image/')
: /\.(png|jpe?g|gif|webp|svg|bmp)(\?|$|#)/i.test(node.file);
// Render note bodies as compiled HTML, like the TextNode does. Resources
// linked from the note body need to be resolved separately — the editor's
// own resourceInfos only covers resources of the *whiteboard* note.
const [noteHtml, setNoteHtml] = useState<string>('');
useEffect(() => {
let cancelled = false;
if (resolved?.kind !== 'note' || !resolved.body) {
setNoteHtml('');
return undefined;
}
void (async () => {
try {
const linkedResources = await attachedResources(resolved.body);
if (cancelled) return;
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
resourceInfos: linkedResources,
});
if (!cancelled) setNoteHtml(result?.html ?? '');
} catch {
if (!cancelled) setNoteHtml('');
}
})();
return () => { cancelled = true; };
}, [resolved, ctx]);
// Save the linked note's body when the user toggles a checkbox in its
// preview. We rely on the same reload-on-external-change path that lets
// other commands (e.g. addNoteToWhiteboard) update notes outside the
// editor's own state — once the body is saved, refetching `resolved`
// happens via `useResolvedRef` which is keyed on `node.file`.
const linkedNoteId = resolved?.kind === 'note' ? resolveFileRef(node.file).id : null;
const linkedNoteUserUpdatedTime = resolved?.kind === 'note' ? resolved.userUpdatedTime : undefined;
const linkedNoteDeletedTime = resolved?.kind === 'note' ? resolved.deletedTime : undefined;
// Per-card in-flight flag. Rapid checkbox toggles can otherwise overwrite
// each other because all clicks read from the same stale `resolved.body`
// until refetch completes. While a save is pending we drop further
// toggles; the user retries once the preview catches up.
const savingRef = useRef(false);
const onLinkedNoteBodyChange = useCallback(async (newBody: string) => {
if (!linkedNoteId) return;
// Don't write to deleted (in-trash) notes — Note.save would either
// fail or, worse, silently resurrect the note via the timestamp bump.
if (linkedNoteDeletedTime) {
logger.info(`Ignoring checkbox toggle on deleted note: ${linkedNoteId}`);
return;
}
if (savingRef.current) {
logger.info(`Dropped concurrent toggle on ${linkedNoteId} — a save is in flight`);
return;
}
savingRef.current = true;
try {
// Pass user_updated_time so the save layer can detect concurrent
// edits (e.g. the same note open in another window). changeSource
// is set explicitly so sync/telemetry can attribute the write.
await Note.save(
{
id: linkedNoteId,
body: newBody,
...(linkedNoteUserUpdatedTime ? { user_updated_time: linkedNoteUserUpdatedTime } : {}),
},
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
);
refetch();
} catch (error) {
// Read-only / shared-without-write-permission notes throw here.
// Log and leave the preview as-is — the next refetch will revert
// the visible checkbox state to match the on-disk body.
logger.warn(`Could not save linked note ${linkedNoteId}:`, error);
refetch();
} finally {
savingRef.current = false;
}
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, refetch]);
const checkboxRef = useCheckboxToggle({
body: resolved?.kind === 'note' ? (resolved.body ?? '') : '',
onChange: onLinkedNoteBodyChange,
});
const renderContent = () => {
// Image / PDF resource — render directly.
if (url && isImage) {
return <img src={url} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', alignSelf: 'center', flex: 1 }} alt={resolved?.title ?? ''} />;
}
if (url && isPdf) {
return <embed src={url} type="application/pdf" style={{ width: '100%', height: '100%' }} />;
}
// Internal note ref — show the note's title in the header and the body
// preview below.
if (resolved?.kind === 'note') {
return (
<>
<div style={noteHeaderStyle(colors.textColor, colors.dividerColor)} title={resolved.title}>{resolved.title}</div>
<div ref={checkboxRef} className="wb-card-md" style={bodyStyle(colors)} dangerouslySetInnerHTML={{ __html: noteHtml }} />
</>
);
}
// Internal resource (non-image / non-pdf) — show its title.
if (resolved?.kind === 'resource') {
return (
<>
<div style={headerStyle(colors)}>Resource</div>
<div style={bodyStyle(colors)}>{resolved.title}</div>
</>
);
}
// Loading or external file path.
return (
<>
<div style={headerStyle(colors)}>{node.file.startsWith(':/') ? 'Note / Resource' : 'File'}</div>
<div style={bodyStyle(colors)}>{resolved === null && node.file.startsWith(':/') ? 'Loading…' : node.file}</div>
</>
);
};
return (
<>
<NodeResizer minWidth={80} minHeight={40} isVisible={!!selected} />
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
))}
<div style={cardStyle(colors, !!selected)} onDoubleClick={onDoubleClick}>
{renderContent()}
</div>
</>
);
};
export default FileNode;
@@ -0,0 +1,34 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { LinkCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import { whiteboardColors } from '../theme';
import { bodyStyle, cardStyle, handlePositions, headerStyle } from './sharedStyles';
const LinkNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbLink'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
const ctx = useWhiteboardContext();
const node = data.canvasNode as LinkCanvasNode;
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
ctx.onOpenRef(node.url);
}, [ctx, node.url]);
return (
<>
<NodeResizer minWidth={80} minHeight={40} isVisible={!!selected} />
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
))}
<div style={cardStyle(colors, !!selected)} onDoubleClick={onDoubleClick}>
<div style={headerStyle(colors)}>Link</div>
<div style={{ ...bodyStyle(colors), wordBreak: 'break-all' }}>{node.url}</div>
</div>
</>
);
};
export default LinkNode;
@@ -0,0 +1,228 @@
import * as React from 'react';
import { CSSProperties, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { MarkupLanguage } from '@joplin/renderer';
import { focus } from '@joplin/lib/utils/focusHandler';
import { _ } from '@joplin/lib/locale';
import { TextCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { useWhiteboardContext, WhiteboardContextValue } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import { replaceStyle } from '../injectStyle';
import { SELECTION_COLOR, WhiteboardThemeColors, whiteboardColors } from '../theme';
import { cardStyle as baseCardStyle, handlePositions } from './sharedStyles';
// Text cards put content directly inside the card div (no inner body wrapper)
// so we extend the shared card style with text-content tokens.
const textCardStyle = (colors: WhiteboardThemeColors, selected: boolean): CSSProperties => ({
...baseCardStyle(colors, selected, 'auto'),
padding: 8,
fontSize: 13,
lineHeight: 1.4,
});
const editTextareaStyle = (textColor: string): CSSProperties => ({
width: '100%',
height: '100%',
border: 'none',
outline: 'none',
resize: 'none',
fontFamily: 'inherit',
fontSize: 13,
lineHeight: 1.4,
color: textColor,
background: 'transparent',
});
const renderedHtmlStyle: CSSProperties = {
flex: 1,
overflow: 'auto',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.4,
};
// Build the theme-dependent stylesheet for in-card markdown rendering. The
// renderer's HTML uses note-viewer CSS variables that don't exist outside the
// iframe, so we provide our own scoped overrides — and, crucially, keep them
// in sync with the active theme so dark mode looks dark.
const buildCardMdCss = (colors: WhiteboardThemeColors): string => `
.wb-card-md h1 { font-size: 16px; margin: 4px 0; }
.wb-card-md h2 { font-size: 15px; margin: 4px 0; }
.wb-card-md h3 { font-size: 14px; margin: 3px 0; }
.wb-card-md h4, .wb-card-md h5, .wb-card-md h6 { font-size: 13px; margin: 3px 0; }
.wb-card-md p { margin: 4px 0; }
.wb-card-md ul, .wb-card-md ol { margin: 4px 0; padding-left: 20px; }
.wb-card-md li { margin: 2px 0; }
.wb-card-md li.md-checkbox, .wb-card-md li.joplin-checkbox { list-style: none; margin-left: -20px; }
.wb-card-md li.md-checkbox input[type=checkbox] { margin-right: 6px; vertical-align: middle; }
.wb-card-md .checkbox-wrapper { display: inline; }
.wb-card-md pre { margin: 4px 0; padding: 6px; font-size: 12px; background: ${colors.codeBackground}; border: 1px solid ${colors.codeBorder}; border-radius: 4px; overflow: auto; color: ${colors.codeColor}; }
.wb-card-md code { font-size: 12px; background: ${colors.codeBackground}; color: ${colors.codeColor}; padding: 1px 4px; border-radius: 3px; }
.wb-card-md pre code { background: transparent; padding: 0; border: none; }
.wb-card-md blockquote { margin: 4px 0; padding-left: 8px; border-left: 3px solid ${colors.blockquoteBorder}; color: ${colors.blockquoteColor}; }
.wb-card-md img { max-width: 100%; height: auto; }
.wb-card-md table { font-size: 12px; border-collapse: collapse; }
.wb-card-md th, .wb-card-md td { padding: 2px 6px; border: 1px solid ${colors.tableBorder}; }
.wb-card-md hr { margin: 6px 0; border: none; border-top: 1px solid ${colors.dividerColor}; }
.wb-card-md a { color: ${colors.linkColor || SELECTION_COLOR}; }
`;
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const [html, setHtml] = useState<string>('');
useEffect(() => {
let cancelled = false;
if (!md) {
setHtml('');
return undefined;
}
void (async () => {
try {
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
resourceInfos: ctx.resourceInfos,
});
if (!cancelled) setHtml(result?.html ?? '');
} catch {
if (!cancelled) setHtml('');
}
})();
return () => { cancelled = true; };
}, [md, ctx]);
return html;
};
const TextNode = ({ data, selected, id }: NodeProps<{ id: string; type: 'wbText'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
const ctx = useWhiteboardContext();
const node = data.canvasNode as TextCanvasNode;
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
// Re-inject the in-card markdown stylesheet whenever the theme changes
// so dark mode swaps in dark code blocks, blockquote tints, etc.
useEffect(() => {
replaceStyle('wb-card-md-style', buildCardMdCss(colors));
}, [colors]);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(node.text);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
if (!editing) setDraft(node.text);
}, [editing, node.text]);
useEffect(() => {
if (editing && textareaRef.current) {
focus('WhiteboardTextNode::beginEdit', textareaRef.current);
textareaRef.current.select();
}
}, [editing]);
const html = useRenderedMarkdown(node.text, ctx);
const beginEdit = useCallback(() => {
setDraft(node.text);
setEditing(true);
}, [node.text]);
const commit = useCallback(() => {
if (!editing) return;
if (draft !== node.text) ctx.onUpdateNode(id, { text: draft });
setEditing(false);
}, [editing, draft, node.text, ctx, id]);
const cancel = useCallback(() => setEditing(false), []);
const onKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}, [commit, cancel]);
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
beginEdit();
}, [beginEdit]);
// Keyboard equivalent of double-click: when the card is focused (React
// Flow makes nodes focusable for tab navigation), Enter or F2 opens the
// editor. Skip when already editing — the textarea has its own handler.
const onCardKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (editing) return;
if (e.key === 'Enter' || e.key === 'F2') {
e.preventDefault();
e.stopPropagation();
beginEdit();
}
}, [editing, beginEdit]);
const onPromote = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
ctx.onPromoteTextNode(id);
}, [ctx, id]);
const onCheckboxToggleBody = useCallback((newBody: string) => {
ctx.onUpdateNode(id, { text: newBody });
}, [ctx, id]);
const checkboxRef = useCheckboxToggle({
body: node.text,
onChange: onCheckboxToggleBody,
});
return (
<>
<NodeResizer minWidth={80} minHeight={40} isVisible={selected && !editing} />
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
))}
<div style={textCardStyle(colors, !!selected)} onDoubleClick={onDoubleClick} onKeyDown={onCardKeyDown}>
{editing ? (
<textarea
ref={textareaRef}
style={editTextareaStyle(colors.textColor)}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={onKeyDown}
className="nodrag"
/>
) : (
node.text
? <div ref={checkboxRef} className="wb-card-md" style={renderedHtmlStyle} dangerouslySetInnerHTML={{ __html: html }} />
: <div style={{ color: colors.mutedColor }}>{_('(empty — double-click to edit)')}</div>
)}
</div>
{selected && !editing && node.text ? (
<button
type="button"
onClick={onPromote}
className="nodrag"
title={_('Convert this card into a Joplin note')}
style={{
position: 'absolute',
top: -10,
right: -10,
padding: '2px 8px',
fontSize: 11,
border: `1px solid ${SELECTION_COLOR}`,
borderRadius: 10,
background: colors.cardBackground,
color: SELECTION_COLOR,
cursor: 'pointer',
zIndex: 5,
}}
>
{_('Promote to note')}
</button>
) : null}
</>
);
};
export default TextNode;
@@ -0,0 +1,53 @@
import { CSSProperties } from 'react';
import { Position } from '@xyflow/react';
import { WhiteboardThemeColors } from '../theme';
// Common card styling shared by Text/File/Link nodes. The `overflow` field
// varies between cards (auto for scrollable text, hidden for media/link
// previews) so it's set per-call.
export const cardStyle = (
colors: WhiteboardThemeColors,
selected: boolean,
overflow: CSSProperties['overflow'] = 'hidden',
): CSSProperties => ({
width: '100%',
height: '100%',
border: selected ? `2px solid ${colors.cardBorderSelected}` : `1px solid ${colors.cardBorder}`,
borderRadius: 6,
background: colors.cardBackground,
overflow,
boxShadow: selected ? colors.cardShadowSelected : colors.cardShadow,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
color: colors.textColor,
});
export const headerStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
fontSize: 11,
color: colors.headerColor,
padding: '4px 8px',
borderBottom: `1px solid ${colors.dividerColor}`,
textTransform: 'uppercase',
letterSpacing: 0.5,
flexShrink: 0,
});
export const bodyStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
flex: 1,
padding: 8,
overflow: 'auto',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.4,
color: colors.textColor,
});
// The four sides shared by all node types — used both for rendering source
// handles around the perimeter and for routing edges to the right anchor.
export const handlePositions: { id: string; position: Position }[] = [
{ id: 'top', position: Position.Top },
{ id: 'right', position: Position.Right },
{ id: 'bottom', position: Position.Bottom },
{ id: 'left', position: Position.Left },
];
@@ -0,0 +1,63 @@
import { themeStyle } from '@joplin/lib/theme';
// The blue accent for "selected" state is shared across selected cards and
// edges. Kept as our own constant rather than pulled from the theme so the
// selection cue stays consistent across light and dark modes.
export const SELECTION_COLOR = '#4a90e2';
export const SELECTION_SHADOW = 'rgba(74,144,226,0.25)';
export interface WhiteboardThemeColors {
// Card / panel surfaces.
cardBackground: string;
cardBorder: string;
cardBorderSelected: string;
cardShadow: string;
cardShadowSelected: string;
// Card text.
textColor: string;
mutedColor: string;
headerColor: string;
// Markdown content inside cards.
codeBackground: string;
codeColor: string;
codeBorder: string;
blockquoteBorder: string;
blockquoteColor: string;
tableBorder: string;
dividerColor: string;
linkColor: string;
// Surface itself (the canvas background) and React Flow handles.
surfaceBackground: string;
handleColor: string;
}
// Translate the active Joplin theme into the colour set our whiteboard uses.
export const whiteboardColors = (themeId: number): WhiteboardThemeColors => {
const theme = themeStyle(themeId);
return {
cardBackground: theme.backgroundColor,
cardBorder: theme.dividerColor,
cardBorderSelected: SELECTION_COLOR,
cardShadow: '0 1px 3px rgba(0,0,0,0.08)',
cardShadowSelected: `0 4px 12px ${SELECTION_SHADOW}`,
textColor: theme.color,
mutedColor: theme.colorFaded || theme.color3 || theme.color,
headerColor: theme.colorFaded || theme.color3 || theme.color,
codeBackground: theme.codeBackgroundColor,
codeColor: theme.codeColor,
codeBorder: theme.codeBorderColor,
blockquoteBorder: theme.dividerColor,
blockquoteColor: theme.colorFaded || theme.color,
tableBorder: theme.dividerColor,
dividerColor: theme.dividerColor,
linkColor: theme.urlColor,
surfaceBackground: theme.backgroundColor3 || theme.backgroundColor,
handleColor: theme.colorFaded || '#888',
};
};
@@ -0,0 +1,53 @@
import { flipNthCheckbox } from './useCheckboxToggle';
// Joplin's renderer only converts `- [ ]` / `- [x]` items inside bullet lists
// into clickable checkboxes — `*`, `+`, and numbered list markers are ignored.
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
// checkbox maps to the Nth source-text checkbox.
describe('flipNthCheckbox', () => {
test('flips an unchecked box to checked', () => {
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
});
test('flips a checked box back to unchecked', () => {
expect(flipNthCheckbox('- [x] done', 0)).toBe('- [ ] done');
expect(flipNthCheckbox('- [X] done', 0)).toBe('- [ ] done');
});
test('targets the Nth checkbox by zero-based index', () => {
const body = '- [ ] one\n- [ ] two\n- [ ] three';
expect(flipNthCheckbox(body, 0)).toBe('- [x] one\n- [ ] two\n- [ ] three');
expect(flipNthCheckbox(body, 1)).toBe('- [ ] one\n- [x] two\n- [ ] three');
expect(flipNthCheckbox(body, 2)).toBe('- [ ] one\n- [ ] two\n- [x] three');
});
test('returns null when index is out of range', () => {
expect(flipNthCheckbox('- [ ] only one', 1)).toBeNull();
expect(flipNthCheckbox('no checkboxes here', 0)).toBeNull();
expect(flipNthCheckbox('', 0)).toBeNull();
});
test('ignores list markers other than `-` to match the renderer', () => {
// `*`, `+`, and numbered markers are not turned into checkboxes by
// Joplin's renderer, so they must not affect the index either.
const body = '* [ ] starred\n+ [ ] plused\n1. [ ] numbered\n- [ ] real';
// The only "real" checkbox is index 0 — it's the dash-prefixed one.
expect(flipNthCheckbox(body, 0)).toBe('* [ ] starred\n+ [ ] plused\n1. [ ] numbered\n- [x] real');
expect(flipNthCheckbox(body, 1)).toBeNull();
});
test('ignores `[ ]` not preceded by a list marker', () => {
expect(flipNthCheckbox('Standalone [ ] not a checkbox', 0)).toBeNull();
});
test('handles Windows line endings', () => {
const body = '- [ ] one\r\n- [ ] two';
expect(flipNthCheckbox(body, 1)).toBe('- [ ] one\r\n- [x] two');
});
test('preserves indentation and the surrounding text', () => {
const body = 'Intro paragraph.\n\n - [ ] indented\n - [x] also indented\n\nOutro.';
expect(flipNthCheckbox(body, 1)).toBe('Intro paragraph.\n\n - [ ] indented\n - [ ] also indented\n\nOutro.');
});
});
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef } from 'react';
// Joplin's renderer emits `<input type="checkbox">` elements for `- [ ]` /
// `- [x]` markdown syntax, with an inline onclick that calls
// `ipcProxySendToHost(...)` — that handler doesn't exist outside the note
// viewer iframe, so clicks are no-ops in our card context.
//
// This hook returns a callback ref. Attach it to the container that holds
// the rendered HTML (`<div ref={checkboxRef} dangerouslySetInnerHTML={...} />`).
// On every change to that container's children, the hook rewires checkboxes
// inside it: it strips the broken inline onclick, makes them enabled, and
// installs a click listener that flips the corresponding `[ ]` / `[x]` in
// the source markdown by index.
interface Options {
body: string;
onChange: (newBody: string)=> void;
}
// Matches a markdown task-list checkbox. The capture group is the inner
// character (' ' or 'x' / 'X'). Joplin's renderer only treats `- [ ]` /
// `- [x]` (dash-prefixed list items) as checkboxes — `*` and `+` markers and
// numbered lists are ignored — so we match exactly that subset, including
// Windows line endings.
const checkboxRegex = /(?<=(?:^|\r?\n)[ \t]*-[ \t]+)\[([ xX])\]/g;
// Exported for tests. Returns the body with the Nth `- [ ]` / `- [x]`
// flipped, or null if there's no Nth checkbox.
export const flipNthCheckbox = (body: string, index: number): string | null => {
let count = 0;
let result = '';
let lastIndex = 0;
let mutated = false;
const regex = new RegExp(checkboxRegex.source, 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(body)) !== null) {
if (count === index) {
const newChar = match[1] === ' ' ? 'x' : ' ';
result += `${body.slice(lastIndex, match.index)}[${newChar}]`;
lastIndex = match.index + match[0].length;
mutated = true;
break;
}
count++;
}
if (!mutated) return null;
return result + body.slice(lastIndex);
};
const useCheckboxToggle = ({ body, onChange }: Options) => {
const bodyRef = useRef(body);
bodyRef.current = body;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const observerRef = useRef<MutationObserver | null>(null);
// Track which checkbox elements have already been wired up. WeakSet so
// removed elements can be GC'd without leaking entries.
const wiredRef = useRef<WeakSet<HTMLInputElement>>(new WeakSet());
const wireUp = useCallback((root: HTMLElement) => {
const checkboxes = root.querySelectorAll<HTMLInputElement>('input[type=checkbox]');
for (const cb of Array.from(checkboxes)) {
if (wiredRef.current.has(cb)) continue;
wiredRef.current.add(cb);
cb.disabled = false;
cb.removeAttribute('disabled');
// Strip the renderer's inline onclick — it tries to call
// ipcProxySendToHost, which we don't expose in this context.
cb.removeAttribute('onclick');
(cb as HTMLInputElement & { onclick: unknown }).onclick = null;
cb.addEventListener('click', (e) => {
e.stopPropagation();
// Recompute the checkbox's current position at click time —
// the wire-time index goes stale after DOM insertions or
// removals (e.g. an external edit added/removed a list item
// before this one).
const current = Array.from(root.querySelectorAll<HTMLInputElement>('input[type=checkbox]'));
const currentIndex = current.indexOf(cb);
if (currentIndex < 0) return;
const next = flipNthCheckbox(bodyRef.current, currentIndex);
if (next !== null) onChangeRef.current(next);
});
cb.addEventListener('mousedown', (e) => e.stopPropagation());
}
}, []);
// Callback ref: fires whenever the underlying element changes (mount,
// unmount, replacement). Using a useCallback identity-stable ref means
// React only invokes it when the element actually changes.
const refCallback = useCallback((el: HTMLDivElement | null) => {
// Tear down previous observer.
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (!el) return;
wireUp(el);
const observer = new MutationObserver(() => wireUp(el));
observer.observe(el, { childList: true, subtree: true });
observerRef.current = observer;
}, [wireUp]);
// Clean up on unmount.
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, []);
return refCallback;
};
export default useCheckboxToggle;
@@ -42,6 +42,8 @@ import ItemChange from '@joplin/lib/models/ItemChange';
import PlainEditor from './NoteBody/PlainEditor/PlainEditor';
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
import WhiteboardEditor from './NoteBody/WhiteboardEditor/WhiteboardEditor';
import { hasWhiteboardFence } from '@joplin/lib/services/whiteboard/parse';
import { openItemById } from './utils/contextMenu';
import { MarkupLanguage } from '@joplin/renderer';
import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions';
@@ -479,7 +481,23 @@ function NoteEditorContent(props: NoteEditorProps) {
let editor = null;
if (builtInEditorVisible) {
const noteHasWhiteboardFence = markupLanguage === MarkupLanguage.Markdown
&& hasWhiteboardFence(formNote.body);
const useWhiteboardEditor = builtInEditorVisible
&& noteHasWhiteboardFence
&& !props.whiteboardForceMarkdown?.[formNote.id];
// Mirror "active note is a whiteboard" to redux so the NoteToolbar can
// show the editor toggle. We can't compute this from the redux note list
// because note bodies aren't in the preview fields.
useEffect(() => {
props.dispatch({ type: 'WHITEBOARD_ACTIVE_NOTE_SET', value: noteHasWhiteboardFence });
}, [noteHasWhiteboardFence, props.dispatch]);
if (useWhiteboardEditor) {
editor = <WhiteboardEditor {...editorProps}/>;
} else if (builtInEditorVisible) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'PlainText') {
@@ -769,6 +787,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
enableInEditorRendering: state.settings['editor.inlineRendering'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
};
};
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
startupPluginsLoaded: boolean;
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
whiteboardForceMarkdown: Record<string, boolean>;
}
export interface NoteBodyEditorRef {
@@ -8,7 +8,8 @@ import { connect } from 'react-redux';
import { buildStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
import { AppState } from '../../app.reducer';
import { stateUtils } from '@joplin/lib/reducer';
import { AppState, AppWindowState } from '../../app.reducer';
interface NoteToolbarProps {
themeId: number;
@@ -51,6 +52,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
const { editorPlugin } = getActivePluginEditorView(state.pluginService.plugins, ownProps.windowId);
const windowState = stateUtils.windowStateById(state, ownProps.windowId) as AppWindowState;
const commands = [
'showSpellCheckerMenu',
@@ -59,7 +61,10 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
'showNoteProperties',
];
if (editorPlugin) commands.push('toggleEditorPlugin');
// `toggleEditorPlugin` shows for plugin editors; we extend it to also
// toggle the core whiteboard editor on whiteboard notes (see the command's
// runtime). The button is the same eye icon either way.
if (editorPlugin || windowState.activeNoteIsWhiteboard) commands.push('toggleEditorPlugin');
return {
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(commands
@@ -0,0 +1,97 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import ItemChange from '@joplin/lib/models/ItemChange';
import Logger from '@joplin/utils/Logger';
import { Mode } from '../../../plugins/GotoAnything';
import { GotoAnythingOptions, UiType } from './gotoAnything';
import { parseWhiteboard } from '@joplin/lib/services/whiteboard/parse';
import { serializeWhiteboard } from '@joplin/lib/services/whiteboard/serialize';
import { CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import generateId from '@joplin/lib/services/whiteboard/generateId';
const logger = Logger.create('addNoteToWhiteboard');
export const declaration: CommandDeclaration = {
name: 'addNoteToWhiteboard',
label: () => _('Add note to whiteboard...'),
};
// Adds a note (chosen via Goto Anything) as a card on the currently open
// whiteboard. The whiteboard is the currently selected note — the command is
// only enabled when that note contains a jsoncanvas fence.
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const targetId = context.state.selectedNoteIds?.[0];
if (!targetId) return;
// Quick gate before opening the picker — if the active note isn't
// a whiteboard, bail out without disturbing the user.
const initial = await Note.load(targetId);
if (!initial) return;
if (!parseWhiteboard(initial.body || '').hasCanvas) {
logger.warn('Active note is not a whiteboard:', targetId);
return;
}
const options: GotoAnythingOptions = { mode: Mode.TitleOnly };
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
if (!result) return;
if (result.type !== ModelType.Note) {
logger.warn('Selected item is not a note:', result);
return;
}
// Reload the note after the picker resolves: between opening the
// picker and now, the whiteboard editor (or a sync) may have
// written a newer body, and we want to append onto the freshest
// persisted state — not the snapshot we read before the prompt.
const fresh = await Note.load(targetId);
if (!fresh) return;
const parsed = parseWhiteboard(fresh.body || '');
if (!parsed.hasCanvas) {
logger.warn('Active note is no longer a whiteboard:', targetId);
return;
}
// Place the new card near the centre of the existing layout, with a
// small offset for each subsequent add so cards don't stack exactly.
const xs = parsed.canvas.nodes.map(n => n.x + n.width / 2);
const ys = parsed.canvas.nodes.map(n => n.y + n.height / 2);
const cx = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
const cy = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : 0;
const offset = (parsed.canvas.nodes.length % 8) * 24;
const newNode: CanvasNode = {
id: generateId(),
type: 'file',
x: cx - 120 + offset,
y: cy - 80 + offset,
width: 240,
height: 160,
file: `:/${result.item.id}`,
};
const nextCanvas = {
...parsed.canvas,
nodes: [...parsed.canvas.nodes, newNode],
};
const newBody = serializeWhiteboard(fresh.body || '', nextCanvas);
// Pass user_updated_time so the save layer can detect concurrent
// edits (the whiteboard editor's own debounced save, or a sync
// write between Note.load above and Note.save here). Mirrors the
// linked-note write path in FileNode.tsx.
await Note.save(
{
id: targetId,
body: newBody,
...(fresh.user_updated_time ? { user_updated_time: fresh.user_updated_time } : {}),
},
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
);
},
enabledCondition: 'oneNoteSelected && activeNoteIsWhiteboard && !noteIsReadOnly',
};
};
@@ -1,4 +1,5 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette';
import * as deleteFolder from './deleteFolder';
@@ -16,6 +17,7 @@ import * as newFolder from './newFolder';
import * as newNote from './newNote';
import * as newSubFolder from './newSubFolder';
import * as newTodo from './newTodo';
import * as newWhiteboard from './newWhiteboard';
import * as openFolder from './openFolder';
import * as openFolderDialog from './openFolderDialog';
import * as openItem from './openItem';
@@ -48,8 +50,10 @@ import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse';
import * as togglePerFolderSortOrder from './togglePerFolderSortOrder';
import * as toggleSideBar from './toggleSideBar';
import * as toggleVisiblePanes from './toggleVisiblePanes';
import * as toggleWhiteboardEditor from './toggleWhiteboardEditor';
const index: any[] = [
addNoteToWhiteboard,
addProfile,
commandPalette,
deleteFolder,
@@ -67,6 +71,7 @@ const index: any[] = [
newNote,
newSubFolder,
newTodo,
newWhiteboard,
openFolder,
openFolderDialog,
openItem,
@@ -99,6 +104,7 @@ const index: any[] = [
togglePerFolderSortOrder,
toggleSideBar,
toggleVisiblePanes,
toggleWhiteboardEditor,
];
export default index;
@@ -3,9 +3,56 @@ import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
import { NoteEntity } from '@joplin/lib/services/database/types';
export const newNoteEnabledConditions = 'oneFolderSelected && selectedFolderIsValid && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
export interface CreateNoteOptions {
body?: string;
title?: string;
isTodo?: boolean;
updateGeolocation?: boolean;
}
// Shared helper used by both `newNote` and any sibling commands that create
// a fresh, provisional note in the currently-active folder (e.g. the
// whiteboard "New whiteboard" command). Returns null if there's no valid
// active folder. Caller is responsible for telling the user the command
// was a no-op in that case (it's already gated by `newNoteEnabledConditions`
// in practice).
export const createNoteInActiveFolder = async (options: CreateNoteOptions = {}): Promise<NoteEntity | null> => {
const folder = await Folder.getValidActiveFolder();
if (!folder) return null;
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
let order;
if (Setting.value('notes.sortOrder.field') === 'order') {
order = await Note.getNextOrderValue(folder.id);
}
const note = await Note.save({
...defaultValues,
parent_id: folder.id,
is_todo: options.isTodo ? 1 : 0,
body: options.body ?? '',
...(options.title ? { title: options.title } : {}),
...(order !== undefined ? { order } : {}),
}, { provisional: true });
if (options.updateGeolocation !== false) {
void Note.updateGeolocation(note.id);
}
utils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
// Immediately sort the note list so that the new note is positioned
// correctly before scrolling to it.
utils.store.dispatch({ type: 'NOTE_SORT' });
return note;
};
export const declaration: CommandDeclaration = {
name: 'newNote',
label: () => _('New note'),
@@ -15,37 +62,7 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, body = '', isTodo = false) => {
const folder = await Folder.getValidActiveFolder();
if (!folder) return;
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
let order;
if (Setting.value('notes.sortOrder.field') === 'order') {
order = await Note.getNextOrderValue(folder.id);
}
let newNote = {
...defaultValues, parent_id: folder.id,
is_todo: isTodo ? 1 : 0,
body: body,
...(order !== undefined ? { order } : {}),
};
newNote = await Note.save(newNote, { provisional: true });
void Note.updateGeolocation(newNote.id);
utils.store.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
// Immediately sort the note list so that the new note is positioned correctly before
// scrolling to it.
utils.store.dispatch({
type: 'NOTE_SORT',
});
await createNoteInActiveFolder({ body, isTodo });
},
enabledCondition: newNoteEnabledConditions,
};
@@ -0,0 +1,25 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { newWhiteboardBody } from '@joplin/lib/services/whiteboard/serialize';
import { createNoteInActiveFolder, newNoteEnabledConditions } from './newNote';
export const declaration: CommandDeclaration = {
name: 'newWhiteboard',
label: () => _('Create whiteboard'),
iconName: 'fa-th',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, title?: string) => {
await createNoteInActiveFolder({
title: title || _('Untitled whiteboard'),
body: newWhiteboardBody(),
// A whiteboard isn't a place-stamped capture; skip the
// reverse-geocode lookup that fires for ordinary new notes.
updateGeolocation: false,
});
},
enabledCondition: newNoteEnabledConditions,
};
};
@@ -0,0 +1,19 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'toggleWhiteboardEditor',
label: () => _('Toggle whiteboard / Markdown view'),
iconName: 'fas fa-eye',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId?: string) => {
const id = noteId || context.state.selectedNoteIds?.[0];
if (!id) return;
context.dispatch({ type: 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE', noteId: id });
},
enabledCondition: 'oneNoteSelected',
};
};
@@ -17,6 +17,9 @@ export default function() {
'newNote',
'newSubFolder',
'newTodo',
'newWhiteboard',
'toggleWhiteboardEditor',
'addNoteToWhiteboard',
'openProfileDirectory',
'print',
'setTags',
+1
View File
@@ -215,6 +215,7 @@
"dependencies": {
"@electron/remote": "2.1.3",
"@joplin/onenote-converter": "~3.6",
"@xyflow/react": "12.10.2",
"fs-extra": "11.3.3",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
@@ -28,6 +28,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
noteListHasNotes: !!windowState.notes.length,
isAltInstance,
activeNoteIsWhiteboard: !!windowState.activeNoteIsWhiteboard,
// Deprecated
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
+15 -1
View File
@@ -3,6 +3,7 @@ import { _ } from '../locale';
import Logger from '@joplin/utils/Logger';
import getActivePluginEditorViews from '../services/plugins/utils/getActivePluginEditorViews';
import getShownPluginEditorView from '../services/plugins/utils/getShownPluginEditorView';
import { stateUtils } from '../reducer';
const logger = Logger.create('toggleEditorPlugin');
@@ -16,6 +17,19 @@ export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const activeWindowId = context.state.windowId;
// The desktop core "Whiteboard" editor reuses this same toggle.
// If the active note is a whiteboard, flip its forced-markdown
// flag instead of touching plugin editor visibility.
const windowState = stateUtils.windowStateById(context.state, activeWindowId) as { activeNoteIsWhiteboard?: boolean };
if (windowState?.activeNoteIsWhiteboard) {
const noteId: string | undefined = context.state.selectedNoteIds?.[0];
if (noteId) {
context.dispatch({ type: 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE', noteId });
return;
}
}
const activePluginStates = getActivePluginEditorViews(context.state.pluginService.plugins, activeWindowId);
if (activePluginStates.length === 0) {
@@ -51,6 +65,6 @@ export const runtime = (): CommandRuntime => {
}
},
enabledCondition: 'hasActivePluginEditor',
enabledCondition: 'hasActivePluginEditor || activeNoteIsWhiteboard',
};
};
@@ -0,0 +1,8 @@
import uuid from '../../uuid';
// Whiteboard nodes and edges need stable, opaque identifiers. We use the
// project's standard short-uuid generator (alphanumeric, 22 chars) so IDs
// are short enough to read in the JSON but still safely unique.
const generateId = (): string => uuid.createNano();
export default generateId;
@@ -0,0 +1,75 @@
// Type definitions for the JSONCanvas 1.0 spec — https://jsoncanvas.org
//
// Joplin extension: a `file` value starting with `:/` is a Joplin internal ID
// reference (note or resource), not a path. Anything else is treated as a
// regular file path / external reference.
export type CanvasNodeSide = 'top' | 'right' | 'bottom' | 'left';
export type CanvasColor =
// Preset palette from the spec: '1' red, '2' orange, '3' yellow,
// '4' green, '5' cyan, '6' purple. Hex strings are also allowed.
| '1' | '2' | '3' | '4' | '5' | '6'
| string;
interface BaseCanvasNode {
id: string;
x: number;
y: number;
width: number;
height: number;
color?: CanvasColor;
}
export interface TextCanvasNode extends BaseCanvasNode {
type: 'text';
text: string;
}
export interface FileCanvasNode extends BaseCanvasNode {
type: 'file';
// In Joplin, this can be `:/<noteId>` or `:/<resourceId>` as well as a
// regular path (for cross-vault interop with other apps).
file: string;
subpath?: string;
}
export interface LinkCanvasNode extends BaseCanvasNode {
type: 'link';
url: string;
}
export interface GroupCanvasNode extends BaseCanvasNode {
type: 'group';
label?: string;
background?: string;
backgroundStyle?: 'cover' | 'ratio' | 'repeat';
}
export type CanvasNode =
| TextCanvasNode
| FileCanvasNode
| LinkCanvasNode
| GroupCanvasNode;
export interface CanvasEdge {
id: string;
fromNode: string;
fromSide?: CanvasNodeSide;
fromEnd?: 'none' | 'arrow';
toNode: string;
toSide?: CanvasNodeSide;
toEnd?: 'none' | 'arrow';
color?: CanvasColor;
label?: string;
}
export interface Canvas {
nodes: CanvasNode[];
edges: CanvasEdge[];
}
export const emptyCanvas = (): Canvas => ({ nodes: [], edges: [] });
// The fence language tag used inside note bodies to mark a JSONCanvas block.
export const fenceTag = 'jsoncanvas';
+121
View File
@@ -0,0 +1,121 @@
import { Canvas, CanvasEdge, CanvasNode, emptyCanvas, fenceTag } from './jsoncanvas';
// Matches the first ` ```jsoncanvas ` fence in a note body. Captures:
// 1: the body before the fence (incl. trailing newline if any)
// 2: the JSON content between the fences
// 3: the body after the closing fence
//
// Fence opener allows optional whitespace and an optional info-string newline.
// Fence closer is a line consisting of just three or more backticks.
const fenceRegex = new RegExp(
`^([\\s\\S]*?)\`\`\`${fenceTag}[ \\t]*\\r?\\n([\\s\\S]*?)\\r?\\n\`\`\`[ \\t]*(?:\\r?\\n|$)([\\s\\S]*)$`,
);
export interface ParseResult {
canvas: Canvas;
prefix: string;
suffix: string;
// True if the fence existed and parsed cleanly. False means the body has
// no fence, or the fence content is not valid JSONCanvas.
hasCanvas: boolean;
// When the fence existed but could not be parsed, this carries a
// human-readable explanation (the JSON parser's message, or a description
// of the schema violation). Null when there's no fence at all, or when
// parsing succeeded.
parseError: string | null;
}
// Cheap precheck — `fenceRegex` uses lazy [\s\S]*? matching that's O(n) on
// strings without a fence, so a literal-substring check first lets us bail
// out without touching the regex engine for any note that isn't a whiteboard.
const fenceMarker = `\`\`\`${fenceTag}`;
export const hasWhiteboardFence = (body: string): boolean => {
if (!body) return false;
if (body.indexOf(fenceMarker) === -1) return false;
return fenceRegex.test(body);
};
const isObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};
const validateNode = (raw: unknown): CanvasNode | null => {
if (!isObject(raw)) return null;
const { id, type, x, y, width, height } = raw;
if (typeof id !== 'string' || !id) return null;
if (typeof type !== 'string') return null;
if (typeof x !== 'number' || typeof y !== 'number') return null;
if (typeof width !== 'number' || typeof height !== 'number') return null;
switch (type) {
case 'text':
if (typeof raw.text !== 'string') return null;
return raw as unknown as CanvasNode;
case 'file':
if (typeof raw.file !== 'string') return null;
return raw as unknown as CanvasNode;
case 'link':
if (typeof raw.url !== 'string') return null;
return raw as unknown as CanvasNode;
case 'group':
return raw as unknown as CanvasNode;
default:
return null;
}
};
const validateEdge = (raw: unknown): CanvasEdge | null => {
if (!isObject(raw)) return null;
const { id, fromNode, toNode } = raw;
if (typeof id !== 'string' || !id) return null;
if (typeof fromNode !== 'string' || !fromNode) return null;
if (typeof toNode !== 'string' || !toNode) return null;
return raw as unknown as CanvasEdge;
};
const validateCanvas = (raw: unknown): Canvas | string => {
if (!isObject(raw)) return 'Top-level JSONCanvas value must be an object with `nodes` and `edges` arrays.';
const rawNodes = Array.isArray(raw.nodes) ? raw.nodes : [];
const rawEdges = Array.isArray(raw.edges) ? raw.edges : [];
const nodes: CanvasNode[] = [];
for (const n of rawNodes) {
const v = validateNode(n);
if (v) nodes.push(v);
}
const edges: CanvasEdge[] = [];
const nodeIds = new Set(nodes.map(n => n.id));
for (const e of rawEdges) {
const v = validateEdge(e);
// Drop edges that point at nodes we couldn't validate — the
// alternative is dangling refs that crash the renderer.
if (v && nodeIds.has(v.fromNode) && nodeIds.has(v.toNode)) edges.push(v);
}
return { nodes, edges };
};
export const parseWhiteboard = (body: string): ParseResult => {
if (!body) return { canvas: emptyCanvas(), prefix: '', suffix: '', hasCanvas: false, parseError: null };
// Same precheck as hasWhiteboardFence — skip the O(n) backtracking regex
// scan when the literal opening marker isn't even present.
if (body.indexOf(fenceMarker) === -1) return { canvas: emptyCanvas(), prefix: body, suffix: '', hasCanvas: false, parseError: null };
const match = body.match(fenceRegex);
if (!match) return { canvas: emptyCanvas(), prefix: body, suffix: '', hasCanvas: false, parseError: null };
const [, prefix, jsonText, suffix] = match;
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { canvas: emptyCanvas(), prefix, suffix, hasCanvas: false, parseError: `Invalid JSON: ${message}` };
}
const canvas = validateCanvas(parsed);
if (typeof canvas === 'string') return { canvas: emptyCanvas(), prefix, suffix, hasCanvas: false, parseError: canvas };
return { canvas, prefix, suffix, hasCanvas: true, parseError: null };
};
@@ -0,0 +1,37 @@
// Classifies the `file` value of a JSONCanvas file-node as a Joplin note ref,
// resource ref, or external path. The Joplin extension to the spec is that
// values starting with `:/` are internal IDs.
export enum RefKind {
Note = 'note',
Resource = 'resource',
External = 'external',
}
export interface ResolvedRef {
kind: RefKind;
// For Note/Resource: the bare ID without the leading `:/`.
// For External: the original value unchanged.
id: string;
}
const internalIdRegex = /^:\/([0-9a-f]{32})(?:#.*)?$/i;
// `kindHint` is needed because note IDs and resource IDs share the same
// 32-char hex format — we can't tell them apart from the string alone. The
// caller knows the context (e.g. it just looked up an item by ID and got
// either a note or a resource back) and passes the result back here.
export const resolveFileRef = (
value: string,
kindHint?: RefKind.Note | RefKind.Resource,
): ResolvedRef => {
const m = value.match(internalIdRegex);
if (m) {
return { kind: kindHint ?? RefKind.Resource, id: m[1] };
}
return { kind: RefKind.External, id: value };
};
export const isInternalRef = (value: string): boolean => {
return internalIdRegex.test(value);
};
@@ -0,0 +1,43 @@
import { Canvas, fenceTag } from './jsoncanvas';
import { parseWhiteboard } from './parse';
const stringifyCanvas = (canvas: Canvas): string => {
// Pretty-print so manual diffs are usable. The cost is bigger note bodies,
// but JSONCanvas files are small in practice and Joplin's note storage
// handles this fine.
return JSON.stringify(canvas, null, '\t');
};
const buildFencedBlock = (canvas: Canvas): string => {
return `\`\`\`${fenceTag}\n${stringifyCanvas(canvas)}\n\`\`\``;
};
// Replace the first jsoncanvas fence in `body` with one carrying `canvas`.
// If no fence exists, append one (preceded by a blank line if the existing
// body is non-empty).
export const serializeWhiteboard = (body: string, canvas: Canvas): string => {
const block = buildFencedBlock(canvas);
const result = parseWhiteboard(body);
if (result.hasCanvas || (result.prefix !== body)) {
// A fence was found (or matched but invalid). Replace it in place.
const prefix = result.prefix;
const suffix = result.suffix;
// Re-glue: prefix + block + (newline if suffix exists) + suffix.
if (suffix.length > 0) {
return `${prefix}${block}\n${suffix}`;
}
return `${prefix}${block}`;
}
// No fence yet: append.
if (!body) return block;
const sep = body.endsWith('\n') ? '\n' : '\n\n';
return `${body}${sep}${block}`;
};
// Returns a note body that contains only an empty canvas — used by the
// "create whiteboard" command.
export const newWhiteboardBody = (): string => {
return buildFencedBlock({ nodes: [], edges: [] });
};
@@ -0,0 +1,164 @@
import { hasWhiteboardFence, parseWhiteboard } from './parse';
import { newWhiteboardBody, serializeWhiteboard } from './serialize';
import { isInternalRef, RefKind, resolveFileRef } from './resolveRef';
import { Canvas } from './jsoncanvas';
const sampleCanvas: Canvas = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 200, height: 100, text: 'Hello' },
{ id: 'b', type: 'file', x: 300, y: 0, width: 200, height: 100, file: ':/0123456789abcdef0123456789abcdef' },
{ id: 'c', type: 'link', x: 0, y: 200, width: 200, height: 100, url: 'https://example.com' },
{ id: 'g', type: 'group', x: -50, y: -50, width: 600, height: 400, label: 'Phase 1' },
],
edges: [
{ id: 'e1', fromNode: 'a', fromSide: 'right', toNode: 'b', toSide: 'left', label: 'depends on' },
],
};
describe('whiteboard format module', () => {
test('parses a body with a jsoncanvas fence and preserves surrounding text', () => {
const before = '# My note\n\nSome intro text.\n\n';
const after = '\nA closing paragraph.\n';
const body = `${before}\`\`\`jsoncanvas\n${JSON.stringify(sampleCanvas)}\n\`\`\`${after}`;
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.prefix).toBe(before);
expect(result.suffix).toBe(after.replace(/^\n/, '')); // leading newline is consumed by the fence terminator
expect(result.canvas.nodes).toHaveLength(4);
expect(result.canvas.edges).toHaveLength(1);
});
test('hasWhiteboardFence detects fence presence', () => {
expect(hasWhiteboardFence('plain note')).toBe(false);
expect(hasWhiteboardFence('```jsoncanvas\n{}\n```')).toBe(true);
expect(hasWhiteboardFence('# title\n\n```jsoncanvas\n{}\n```\n')).toBe(true);
});
test('returns empty canvas with hasCanvas=false when fence content is invalid JSON', () => {
const body = '```jsoncanvas\nthis is not json\n```';
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(false);
expect(result.canvas.nodes).toHaveLength(0);
expect(result.canvas.edges).toHaveLength(0);
expect(result.parseError).toMatch(/^Invalid JSON: /);
});
test('reports a schema error when the JSON is valid but not a JSONCanvas object', () => {
const body = '```jsoncanvas\n[]\n```';
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(false);
expect(result.parseError).toMatch(/JSONCanvas/);
});
test('parseError is null when there is no fence at all', () => {
expect(parseWhiteboard('').parseError).toBeNull();
expect(parseWhiteboard('plain note').parseError).toBeNull();
});
test('drops invalid nodes and edges that reference unknown nodes', () => {
const dirty = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'ok' },
{ id: 'b', type: 'text', x: 0, y: 0 }, // missing dimensions
{ type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'no id' },
],
edges: [
{ id: 'e1', fromNode: 'a', toNode: 'a' },
{ id: 'e2', fromNode: 'a', toNode: 'ghost' },
],
};
const body = `\`\`\`jsoncanvas\n${JSON.stringify(dirty)}\n\`\`\``;
const result = parseWhiteboard(body);
expect(result.canvas.nodes.map(n => n.id)).toEqual(['a']);
expect(result.canvas.edges.map(e => e.id)).toEqual(['e1']);
});
test('round-trips: serialize then parse yields the same canvas', () => {
const body = serializeWhiteboard('', sampleCanvas);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas).toEqual(sampleCanvas);
});
test('serialize replaces an existing fence in place and preserves prefix/suffix', () => {
const original = `Intro\n\n\`\`\`jsoncanvas\n${JSON.stringify({ nodes: [], edges: [] })}\n\`\`\`\n\nOutro\n`;
const updated = serializeWhiteboard(original, sampleCanvas);
const result = parseWhiteboard(updated);
expect(result.canvas).toEqual(sampleCanvas);
expect(result.prefix).toBe('Intro\n\n');
expect(updated.endsWith('Outro\n')).toBe(true);
});
test('serialize appends a fence when none exists', () => {
const before = '# Title\n\nSome content.';
const updated = serializeWhiteboard(before, sampleCanvas);
expect(updated.startsWith(before)).toBe(true);
expect(hasWhiteboardFence(updated)).toBe(true);
});
test('newWhiteboardBody produces a parseable empty canvas', () => {
const body = newWhiteboardBody();
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.nodes).toEqual([]);
expect(result.canvas.edges).toEqual([]);
});
test('round-trips edge fromEnd / toEnd / sides without losing or flipping them', () => {
const canvas: Canvas = {
nodes: [
{ id: 'a', type: 'text', x: 0, y: 0, width: 100, height: 100, text: 'a' },
{ id: 'b', type: 'text', x: 200, y: 0, width: 100, height: 100, text: 'b' },
],
edges: [
{ id: 'forward', fromNode: 'a', toNode: 'b', toEnd: 'arrow' },
{ id: 'backward', fromNode: 'a', toNode: 'b', fromEnd: 'arrow', toEnd: 'none' },
{ id: 'both', fromNode: 'a', toNode: 'b', fromEnd: 'arrow', toEnd: 'arrow' },
{ id: 'sided', fromNode: 'a', toNode: 'b', fromSide: 'right', toSide: 'left' },
],
};
const body = serializeWhiteboard('', canvas);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.edges).toEqual(canvas.edges);
});
test('preserves group nodes through parse + serialize', () => {
// Joplin's editor doesn't render group nodes but must not silently
// drop them on save — other tools may contain them.
const canvas: Canvas = {
nodes: [
{ id: 'g', type: 'group', x: -50, y: -50, width: 400, height: 300, label: 'Phase 1', background: '#eee' },
{ id: 't', type: 'text', x: 0, y: 0, width: 100, height: 60, text: 'inside' },
],
edges: [],
};
const body = serializeWhiteboard('', canvas);
const result = parseWhiteboard(body);
expect(result.hasCanvas).toBe(true);
expect(result.canvas.nodes).toEqual(canvas.nodes);
});
test.each([
[':/0123456789abcdef0123456789abcdef', RefKind.Resource, '0123456789abcdef0123456789abcdef'],
['https://example.com/foo.pdf', RefKind.External, 'https://example.com/foo.pdf'],
['Notes/Foo.md', RefKind.External, 'Notes/Foo.md'],
])('resolveFileRef classifies %s', (value, kind, id) => {
const r = resolveFileRef(value);
expect(r.kind).toBe(kind);
expect(r.id).toBe(id);
});
test('resolveFileRef respects kindHint for internal refs', () => {
const r = resolveFileRef(':/0123456789abcdef0123456789abcdef', RefKind.Note);
expect(r.kind).toBe(RefKind.Note);
});
test('isInternalRef matches only proper :/ id strings', () => {
expect(isInternalRef(':/0123456789abcdef0123456789abcdef')).toBe(true);
expect(isInternalRef(':/short')).toBe(false);
expect(isInternalRef('not internal')).toBe(false);
});
});
+16
View File
@@ -267,3 +267,19 @@ pdate
coderabbitai
cleanup
Rygaa
xyflow
jsoncanvas
pannable
zoomable
webp
nodrag
Outro
parseable
connectingfrom
connectingto
textbg
updaters
plused
connectionline
precheck
unparseable
+66
View File
@@ -0,0 +1,66 @@
# Whiteboard support
Whiteboards, also known as "canvas", allow organising notes spatially on an infinite zoomable surface. Instead of editing prose top-to-bottom, you arrange text cards, images, PDFs, links and references to other notes anywhere on a 2D surface, and connect them with labelled arrows.
Whiteboards are stored as ordinary Joplin notes — the data lives inside the note body as a fenced code block in the open [JSONCanvas](https://jsoncanvas.org/) format — so they sync, search, share, back up and convert just like any other note.
## Availability
Whiteboard editing is available on the desktop app only. Mobile read/edit support is planned as a follow-up.
## Creating a whiteboard
To create a new whiteboard, open **Tools → Create whiteboard**.
## The editor
A whiteboard note opens directly in the editor. The view supports panning (click-drag on empty space, or scroll), zooming (Ctrl/Cmd + scroll), and includes a minimap and zoom controls in the corner.
### Adding cards
- **+ Text** (top-right action panel) — adds a new text card near the centre of the visible viewport. Double-click the card or press **Enter** when it's focused to edit; the body supports Markdown, including headings, lists, code blocks, and checkboxes.
- **Drag and drop from Joplin** — drag a note from the note list, or an attachment, onto the whiteboard to create a card linking to it.
- **Tools → Add note to whiteboard** — opens the note picker and adds a card linking to the chosen Joplin note. The card shows the note's title and a live preview of its body, with working checkboxes.
### Connecting cards
Hover over a card to reveal connection handles on its four sides. Click and drag from a handle to another card to create an edge.
When one or more edges are selected, an action panel at the bottom of the whiteboard lets you:
- Change the arrowhead direction: none (`—`), forward (`→`), backward (`←`), or bidirectional (`↔`).
- Swap source and target with **Flip**.
- Add a label that's shown along the edge.
### Promoting a text card to a real note
If a text card has grown into something that deserves its own note, select it and click **Promote to note**. This creates a new Joplin note in the same notebook as the whiteboard, with the card's text as the body and its first non-empty line as the title, then replaces the text card on the whiteboard with a link to the new note.
### Switching to the Markdown view
The "eye" icon in the note toolbar toggles the note between the whiteboard editor and the standard Markdown editor. In Markdown view, you can read and hand-edit the underlying JSONCanvas data, add prose before or after the fence, etc. Editing the JSON directly is supported but advanced — invalid JSON will prevent the whiteboard from loading until it's repaired.
## Accessibility
The Whiteboard is an inherently visual-spatial tool, but a few affordances make it usable beyond mouse-only interaction:
- **Eye toggle as a text fallback.** The "eye" icon in the toolbar switches the note to the Markdown editor, where the whiteboard is shown as its underlying JSONCanvas text. Every text card's content, every link target, and every edge label is plain text in that view, so screen-reader users (and full-text search, and external tools) can read and edit a whiteboard's content without using the visual editor at all.
- **Keyboard navigation.** Cards are reachable by Tab. Press **Enter** or **F2** on a focused text card to enter edit mode, **Esc** to cancel, and **Cmd/Ctrl + Enter** to commit the edit.
- **Toolbar buttons** in the action panels expose accessible labels for screen readers and voice-control users.
## How are whiteboards stored?
A whiteboard note is a regular Markdown note whose body contains a fenced code block tagged `jsoncanvas`:
````
```jsoncanvas
{
"nodes": [ ... ],
"edges": [ ... ]
}
```
````
The format is [JSONCanvas 1.0](https://jsoncanvas.org/), an open specification for canvas. This means a whiteboard you create in Joplin can be opened (and round-tripped) in any other JSONCanvas-compatible editor, and vice versa. Group nodes and other JSONCanvas features the desktop editor doesn't render are preserved on save so they survive a round trip.
Because the whiteboard is part of the note body, all of Joplin's existing infrastructure works on whiteboards without modification: synchronisation, end-to-end encryption, full-text search across card text, sharing via Joplin Cloud, conflict resolution, revision history, and export/import.
+77 -9
View File
@@ -12286,6 +12286,7 @@ __metadata:
"@types/react-dom": "npm:19.1.7"
"@types/react-redux": "npm:7.1.34"
"@types/styled-components": "npm:5.1.36"
"@xyflow/react": "npm:12.10.2"
async-mutex: "npm:0.5.0"
axios: "npm:^1.7.7"
codemirror: "npm:5.65.9"
@@ -18075,7 +18076,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-drag@npm:*":
"@types/d3-drag@npm:*, @types/d3-drag@npm:^3.0.7":
version: 3.0.7
resolution: "@types/d3-drag@npm:3.0.7"
dependencies:
@@ -18137,7 +18138,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-interpolate@npm:*":
"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.4":
version: 3.0.4
resolution: "@types/d3-interpolate@npm:3.0.4"
dependencies:
@@ -18190,7 +18191,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-selection@npm:*":
"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10":
version: 3.0.11
resolution: "@types/d3-selection@npm:3.0.11"
checksum: 10/2d2d993b9e9553d066566cb22916c632e5911090db99e247bd8c32855a344e6b7c25b674f3c27956c367a6b3b1214b09931ce854788c3be2072003e01f2c75d7
@@ -18227,7 +18228,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-transition@npm:*":
"@types/d3-transition@npm:*, @types/d3-transition@npm:^3.0.8":
version: 3.0.9
resolution: "@types/d3-transition@npm:3.0.9"
dependencies:
@@ -18236,7 +18237,7 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-zoom@npm:*":
"@types/d3-zoom@npm:*, @types/d3-zoom@npm:^3.0.8":
version: 3.0.8
resolution: "@types/d3-zoom@npm:3.0.8"
dependencies:
@@ -20463,6 +20464,37 @@ __metadata:
languageName: node
linkType: hard
"@xyflow/react@npm:12.10.2":
version: 12.10.2
resolution: "@xyflow/react@npm:12.10.2"
dependencies:
"@xyflow/system": "npm:0.0.76"
classcat: "npm:^5.0.3"
zustand: "npm:^4.4.0"
peerDependencies:
react: ">=17"
react-dom: ">=17"
checksum: 10/92f341f31d50ca1c4886811fca4c1e41e3ce1ebb4f8abf3e7a75af91228866af4ffab7c36d15937fdbd046a5662e4b2e6d6773ef9be977b95074a568bafb0fcf
languageName: node
linkType: hard
"@xyflow/system@npm:0.0.76":
version: 0.0.76
resolution: "@xyflow/system@npm:0.0.76"
dependencies:
"@types/d3-drag": "npm:^3.0.7"
"@types/d3-interpolate": "npm:^3.0.4"
"@types/d3-selection": "npm:^3.0.10"
"@types/d3-transition": "npm:^3.0.8"
"@types/d3-zoom": "npm:^3.0.8"
d3-drag: "npm:^3.0.0"
d3-interpolate: "npm:^3.0.1"
d3-selection: "npm:^3.0.0"
d3-zoom: "npm:^3.0.0"
checksum: 10/6fada3ea584b41007a8f10160046665a940a4302b85f7ccbd2169bf1109a2e070ddbededd3f4f4e2479234e9bb0a500406c7718f6d4b5328575be59bc6655a84
languageName: node
linkType: hard
"@zkochan/cmd-shim@npm:^3.1.0":
version: 3.1.0
resolution: "@zkochan/cmd-shim@npm:3.1.0"
@@ -24722,6 +24754,13 @@ __metadata:
languageName: node
linkType: hard
"classcat@npm:^5.0.3":
version: 5.0.5
resolution: "classcat@npm:5.0.5"
checksum: 10/19bdeb99b8923b47f9df978b6ef2c5a4cc3bcaa8fb6be16244e31fad619b291b366429747331903ac2ea27560ffd6066d14089a99c95535ce0f1e897525fa63d
languageName: node
linkType: hard
"clean-css@npm:^4.2.1":
version: 4.2.4
resolution: "clean-css@npm:4.2.4"
@@ -27136,7 +27175,7 @@ __metadata:
languageName: node
linkType: hard
"d3-drag@npm:2 - 3, d3-drag@npm:3":
"d3-drag@npm:2 - 3, d3-drag@npm:3, d3-drag@npm:^3.0.0":
version: 3.0.0
resolution: "d3-drag@npm:3.0.0"
dependencies:
@@ -27217,7 +27256,7 @@ __metadata:
languageName: node
linkType: hard
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3":
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3, d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
@@ -27294,7 +27333,7 @@ __metadata:
languageName: node
linkType: hard
"d3-selection@npm:2 - 3, d3-selection@npm:3":
"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
version: 3.0.0
resolution: "d3-selection@npm:3.0.0"
checksum: 10/0e5acfd305b31628b7be5009ba7303d84bb34817a88ed4dde9c8bd9c23528573fc5272f89fc04e5be03d2cbf5441a248d7274aaf55a8ef3dad46e16333d72298
@@ -27359,7 +27398,7 @@ __metadata:
languageName: node
linkType: hard
"d3-zoom@npm:3":
"d3-zoom@npm:3, d3-zoom@npm:^3.0.0":
version: 3.0.0
resolution: "d3-zoom@npm:3.0.0"
dependencies:
@@ -58055,6 +58094,15 @@ __metadata:
languageName: node
linkType: hard
"use-sync-external-store@npm:^1.2.2":
version: 1.6.0
resolution: "use-sync-external-store@npm:1.6.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e
languageName: node
linkType: hard
"use@npm:^3.1.0":
version: 3.1.1
resolution: "use@npm:3.1.1"
@@ -60428,6 +60476,26 @@ __metadata:
languageName: node
linkType: hard
"zustand@npm:^4.4.0":
version: 4.5.7
resolution: "zustand@npm:4.5.7"
dependencies:
use-sync-external-store: "npm:^1.2.2"
peerDependencies:
"@types/react": ">=16.8"
immer: ">=9.0.6"
react: ">=16.8"
peerDependenciesMeta:
"@types/react":
optional: true
immer:
optional: true
react:
optional: true
checksum: 10/21c47ea1c9bb0363b714a7e371a91b9afaeabc5c9c2f522803a0fb412605b1e037c4f975a7377529de8f2857e60d1f4586e7ade18444168ecc492e38779e605d
languageName: node
linkType: hard
"zwitch@npm:^1.0.0":
version: 1.0.5
resolution: "zwitch@npm:1.0.5"