diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 4bb4c11dd67..79bf1ac6670 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1806,6 +1806,35 @@ "multiline-toolbar": "Display the toolbar on multiple lines if it doesn't fit." } }, + "toolbar_customization": { + "title": "Toolbar customization", + "description": "Arrange toolbar buttons by dragging rows up and down. The order here matches the left-to-right order in the editor. Click a chip below to add it, or drag it into the list above.", + "tab_classic": "Classic toolbar", + "tab_floating": "Floating toolbar", + "active_section": "Active toolbar items — top = leftmost in editor", + "available_section": "Available items — click to add", + "all_active": "All items are already in the toolbar", + "drop_to_remove": "Drop here to remove from toolbar", + "release_to_remove_from_toolbar": "Release to remove from toolbar", + "drag_to_remove_from_toolbar": "Drag here to remove from toolbar", + "release_to_remove": "Release to remove", + "drag_here": "Drag items from below, or click a chip to add", + "pool_hint": "Click a chip to append it, or drag it into the list above to insert at a specific position", + "add_separator": "Separator", + "add_separator_hint": "Add a visual separator | between items", + "add_group": "Group (···)", + "add_group_hint": "Add a dropdown group that appears as a ··· button in the editor. Drag items onto the group row to fill it.", + "group_empty": "Drop items here or drag from the pool below", + "separator": "separator", + "drop_on_group": "Drop a pool item here to add it to this group", + "expand": "Expand group", + "collapse": "Collapse group", + "remove_item": "Remove from toolbar", + "save": "Save & Reload", + "save_title": "Save changes and reload the page to apply the new toolbar layout", + "reset": "Reset to defaults", + "reset_title": "Remove all customizations and restore the built-in toolbar layout" + }, "electron_context_menu": { "add-term-to-dictionary": "Add \"{{term}}\" to dictionary", "cut": "Cut", diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx index e2ff88b447a..4ecaecc654e 100644 --- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx +++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx @@ -22,6 +22,7 @@ import { getHtml } from "../../react/RawHtml"; import AutoReadOnlySize from "./components/AutoReadOnlySize"; import CheckboxList from "./components/CheckboxList"; import OptionsSection from "./components/OptionsSection"; +import ToolbarCustomization from "./toolbar_customization"; const isNewLayout = isExperimentalFeatureEnabled("new-layout"); @@ -29,6 +30,7 @@ export default function TextNoteSettings() { return ( <> + diff --git a/apps/client/src/widgets/type_widgets/options/toolbar_customization.tsx b/apps/client/src/widgets/type_widgets/options/toolbar_customization.tsx new file mode 100644 index 00000000000..f6de6303545 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/toolbar_customization.tsx @@ -0,0 +1,882 @@ +/** + * Toolbar customization – vertical single-column editor. + * + * Top → Active toolbar items (drag to reorder; top = leftmost in editor) + * Bottom → Available items pool (click to append, or drag into the list) + * + * Changes are kept in local state; only "Save & Reload" persists them. + */ +import { useState } from "preact/hooks"; + +import IconAlignLeft from "@ckeditor/ckeditor5-icons/theme/icons/align-left.svg?raw"; +import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw"; +import IconAlignRight from "@ckeditor/ckeditor5-icons/theme/icons/align-right.svg?raw"; +import IconAlignJustify from "@ckeditor/ckeditor5-icons/theme/icons/align-justify.svg?raw"; +import IconPageBreak from "@ckeditor/ckeditor5-icons/theme/icons/page-break.svg?raw"; + +import { t } from "../../../services/i18n"; +import { useTriliumOption } from "../../react/hooks"; +import OptionsSection from "./components/OptionsSection"; +import { + DEFAULT_CLASSIC_TOOLBAR, + DEFAULT_FLOATING_TOOLBAR, + getDefaultConfig, + getItemLabel, + TOOLBAR_ITEM_LABELS, + type ToolbarCustomConfig, + type ToolbarEntry, + type ToolbarGroup, + type ToolbarItem, + type ToolbarSeparator, +} from "../text/toolbar_config"; + +// Only Classic and Floating — Block toolbar is an internal CKEditor detail +type TabKey = "classic" | "floating"; + +// ─── Icon maps ──────────────────────────────────────────────────────────────── + +const BX_ICON: Record = { + "kbd": "bx-chip", + "formatPainter": "bx-copy-alt", + "fontColor": "bx-palette", + "fontBackgroundColor":"bx-palette", + "removeFormat": "bx-reset", + "bulletedList": "bx-list-ul", + "numberedList": "bx-list-ol", + "todoList": "bx-list-check", + "admonition": "bx-info-circle", + "insertTable": "bx-table", + "code": "bx-code", + "codeBlock": "bx-code-curly", + "footnote": "bx-note", + "imageUpload": "bx-image", + "link": "bx-link", + "bookmark": "bx-bookmark", + "internallink": "bx-link-external", + "includeNote": "bx-file", + "mermaid": "bx-network-chart", + "horizontalLine": "bx-minus", + "dateTime": "bx-calendar", + "outdent": "bx-chevrons-left", + "indent": "bx-chevrons-right", + "markdownImport": "bx-import", + "insertTemplate": "bx-columns", + "cuttonote": "bx-transfer", + "specialCharacters": "bx-font", +}; + +const SVG_ICON: Record = { + "alignment:left": IconAlignLeft, + "alignment:center": IconAlignCenter, + "alignment:right": IconAlignRight, + "alignment:justify": IconAlignJustify, + "pageBreak": IconPageBreak, +}; + +function textFallback(id: string): { char: string; css: preact.JSX.CSSProperties } { + switch (id) { + case "bold": return { char: "B", css: { fontWeight: "bold" } }; + case "italic": return { char: "I", css: { fontStyle: "italic" } }; + case "underline": return { char: "U", css: { textDecoration: "underline" } }; + case "strikethrough": return { char: "S", css: { textDecoration: "line-through" } }; + case "superscript": return { char: "x²", css: {} }; + case "subscript": return { char: "x₂", css: {} }; + case "heading": return { char: "H", css: { fontWeight: "bold" } }; + case "fontSize": return { char: "Aa", css: {} }; + case "blockQuote": return { char: "❝", css: {} }; + case "emoji": return { char: "☺", css: {} }; + case "math": return { char: "∑", css: {} }; + default: return { char: getItemLabel(id).slice(0, 2).toUpperCase(), css: {} }; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function parseConfig(raw: string): ToolbarCustomConfig { + if (!raw) return getDefaultConfig(); + try { return JSON.parse(raw) as ToolbarCustomConfig; } + catch { return getDefaultConfig(); } +} + +function collectItemIds(entries: ToolbarEntry[]): string[] { + const ids: string[] = []; + for (const e of entries) { + if (e.kind === "item") ids.push(e.id); + else if (e.kind === "group") { + for (const c of e.items) if (c.kind === "item") ids.push(c.id); + } + } + return ids; +} + +function getPool(entries: ToolbarEntry[]): string[] { + const used = new Set(collectItemIds(entries)); + return Object.keys(TOOLBAR_ITEM_LABELS).filter(id => !used.has(id)); +} + +function rowInsertIdx(e: DragEvent, rowIdx: number): number { + const rect = (e.currentTarget as Element).getBoundingClientRect(); + return e.clientY < rect.top + rect.height / 2 ? rowIdx : rowIdx + 1; +} + +// ─── Design tokens ──────────────────────────────────────────────────────────── + +const COLOR = { + border: "var(--bs-border-color, #dee2e6)", + // Solid border — translucent version is too faint in dark mode + rowSep: "var(--bs-border-color, #dee2e6)", + // rgba white overlay: in dark mode adds lightness, in light mode barely visible (fine) + hoverOverlay: "rgba(255,255,255,0.06)", + // rgba tint — works in both light AND dark mode (adds blue to whatever bg is there) + groupBg: "rgba(13, 110, 253, 0.18)", + groupBorder: "var(--bs-primary, #0d6efd)", + muted: "var(--bs-secondary-color, #6c757d)", + danger: "var(--bs-danger, #dc3545)", + bodyBg: "var(--bs-body-bg, #fff)", + poolBg: "var(--bs-tertiary-bg, #f8f9fa)", + childBg: "var(--bs-secondary-bg, #e9ecef)", +}; + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function ToolbarCustomization() { + const [savedRaw, setSavedRaw] = useTriliumOption("textNoteToolbarConfig"); + const [local, setLocal] = useState(() => parseConfig(savedRaw)); + const [tab, setTab] = useState("classic"); + + const isDirty = JSON.stringify(local) !== JSON.stringify(parseConfig(savedRaw)); + + function updateTab(newEntries: ToolbarEntry[]) { + setLocal(prev => ({ ...prev, [tab]: newEntries })); + } + + function saveAndReload() { + setSavedRaw(JSON.stringify(local)); + setTimeout(() => window.location.reload(), 120); + } + + const TAB_LABEL: Record = { + classic: t("toolbar_customization.tab_classic"), + floating: t("toolbar_customization.tab_floating"), + }; + + return ( + +

+ {t("toolbar_customization.description")} +

+ + {/* Tab bar */} + + + + + {/* Bottom actions */} +
+ + +
+
+ ); +} + +// ─── Vertical editor ────────────────────────────────────────────────────────── + +type DragSrc = + | { from: "pool"; id: string } + | { from: "active"; idx: number } + | { from: "child"; groupIdx: number; childIdx: number }; + +interface VerticalEditorProps { + entries: ToolbarEntry[]; + onChange: (e: ToolbarEntry[]) => void; +} + +function VerticalEditor({ entries, onChange }: VerticalEditorProps) { + const pool = getPool(entries); + + const [drag, setDrag] = useState(null); + const [activeDropIdx, setActiveDrop] = useState(null); + const [expandedGroup, setExpanded] = useState(null); + const [childDropIdx, setChildDrop] = useState(null); + const [poolOver, setPoolOver] = useState(false); + + // ── Mutations ───────────────────────────────────────────────────────────── + + function commit(next: ToolbarEntry[]) { + onChange(next.map(e => { + if (e.kind === "item") return { ...e, visible: true }; + if (e.kind === "group") return { ...e, visible: true, items: e.items.map(c => c.kind === "item" ? { ...c, visible: true } : c) }; + return e; + })); + } + + function appendItem(id: string) { + commit([...entries, { kind: "item", id, visible: true } as ToolbarItem]); + } + + function insertAt(id: string, at: number) { + const next = [...entries]; + next.splice(at, 0, { kind: "item", id, visible: true } as ToolbarItem); + commit(next); + } + + function moveActive(from: number, to: number) { + if (from === to) return; + const next = [...entries]; + const [m] = next.splice(from, 1); + next.splice(to > from ? to - 1 : to, 0, m); + commit(next); + } + + function removeAt(idx: number) { commit(entries.filter((_, i) => i !== idx)); } + + function addSeparator() { commit([...entries, { kind: "separator" } as ToolbarSeparator]); } + + function addGroup() { + const id = `group_${crypto.randomUUID()}`; + const g: ToolbarGroup = { kind: "group", id, label: "Group", icon: "threeVerticalDots", visible: true, items: [] }; + commit([...entries, g]); + setExpanded(id); + } + + function addToGroup(groupIdx: number, id: string) { + const next = [...entries]; + const g = { ...(next[groupIdx] as ToolbarGroup) }; + g.items = [...g.items, { kind: "item", id, visible: true } as ToolbarItem]; + next[groupIdx] = g; + commit(next); + } + + function removeFromGroup(groupIdx: number, childIdx: number) { + const next = [...entries]; + const g = { ...(next[groupIdx] as ToolbarGroup) }; + g.items = g.items.filter((_, i) => i !== childIdx); + next[groupIdx] = g; + commit(next); + } + + function moveChild(groupIdx: number, from: number, to: number) { + if (from === to) return; + const next = [...entries]; + const g = { ...(next[groupIdx] as ToolbarGroup) }; + const ch = [...g.items]; + const [m] = ch.splice(from, 1); + ch.splice(to > from ? to - 1 : to, 0, m); + g.items = ch; + next[groupIdx] = g; + commit(next); + } + + // ── Drag helpers ────────────────────────────────────────────────────────── + + function startDrag(e: DragEvent, src: DragSrc) { + setDrag(src); + e.dataTransfer!.effectAllowed = "move"; + } + + function clearDrag() { setDrag(null); setActiveDrop(null); setChildDrop(null); setPoolOver(false); } + + function onPoolDrop(e: DragEvent) { + e.preventDefault(); + if (!drag) return clearDrag(); + if (drag.from === "active") removeAt(drag.idx); + else if (drag.from === "child") removeFromGroup(drag.groupIdx, drag.childIdx); + clearDrag(); + } + + function onActiveRowOver(e: DragEvent, rowIdx: number) { + e.preventDefault(); + e.stopPropagation(); // prevent container's onDragOver from overwriting activeDrop + setActiveDrop(rowInsertIdx(e, rowIdx)); + } + + function onActiveDrop(e: DragEvent, at: number) { + e.preventDefault(); + if (!drag) return; + if (drag.from === "pool") insertAt(drag.id, at); + else if (drag.from === "active") moveActive(drag.idx, at); + clearDrag(); + } + + function onGroupRowDrop(e: DragEvent, groupIdx: number) { + e.preventDefault(); e.stopPropagation(); + if (!drag || drag.from !== "pool") return; + addToGroup(groupIdx, drag.id); + clearDrag(); + } + + function onChildRowOver(e: DragEvent, groupIdx: number, ci: number) { + e.preventDefault(); e.stopPropagation(); + setChildDrop(rowInsertIdx(e, ci)); + } + + function onChildDrop(e: DragEvent, groupIdx: number, at: number) { + e.preventDefault(); e.stopPropagation(); + if (!drag) return; + if (drag.from === "pool") addToGroup(groupIdx, drag.id); + else if (drag.from === "child" && drag.groupIdx === groupIdx) moveChild(groupIdx, drag.childIdx, at); + clearDrag(); + } + + const draggingIdx = drag?.from === "active" ? drag.idx : null; + // True when an active/child item is being dragged — pool becomes a remove zone + const isDraggingToPool = drag !== null && (drag.from === "active" || drag.from === "child"); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ + {/* ── Section header: Active ── */} + + + {/* ── Active list ── */} +
{ e.preventDefault(); setActiveDrop(entries.length); }} + onDrop={e => onActiveDrop(e as DragEvent, entries.length)} + > + {entries.length === 0 && ( +
+ {t("toolbar_customization.drag_here")} +
+ )} + + {entries.map((entry, i) => ( +
+ + + {entry.kind === "separator" ? ( + startDrag(e as DragEvent, { from: "active", idx: i })} + onDragEnd={clearDrag} + onDragOver={e => onActiveRowOver(e as DragEvent, i)} + onDrop={e => onActiveDrop(e as DragEvent, rowInsertIdx(e as DragEvent, i))} + onRemove={() => removeAt(i)} + /> + ) : entry.kind === "group" ? ( + <> + setExpanded(v => v === entry.id ? null : entry.id)} + onDragStart={e => startDrag(e as DragEvent, { from: "active", idx: i })} + onDragEnd={clearDrag} + onDragOver={e => onActiveRowOver(e as DragEvent, i)} + onDrop={e => onGroupRowDrop(e as DragEvent, i)} + onRemove={() => removeAt(i)} + /> + {expandedGroup === entry.id && ( +
+ {entry.items.length === 0 && ( +
+ {t("toolbar_customization.group_empty")} +
+ )} + {entry.items.map((c, ci) => ( +
+ + {c.kind === "separator" ? ( + startDrag(e as DragEvent, { from: "child", groupIdx: i, childIdx: ci })} + onDragEnd={clearDrag} + onDragOver={e => onChildRowOver(e as DragEvent, i, ci)} + onDrop={e => onChildDrop(e as DragEvent, i, rowInsertIdx(e as DragEvent, ci))} + onRemove={() => removeFromGroup(i, ci)} + /> + ) : ( + startDrag(e as DragEvent, { from: "child", groupIdx: i, childIdx: ci })} + onDragEnd={clearDrag} + onDragOver={e => onChildRowOver(e as DragEvent, i, ci)} + onDrop={e => onChildDrop(e as DragEvent, i, rowInsertIdx(e as DragEvent, ci))} + onRemove={() => removeFromGroup(i, ci)} + /> + )} +
+ ))} + +
+ )} + + ) : ( + startDrag(e as DragEvent, { from: "active", idx: i })} + onDragEnd={clearDrag} + onDragOver={e => onActiveRowOver(e as DragEvent, i)} + onDrop={e => onActiveDrop(e as DragEvent, rowInsertIdx(e as DragEvent, i))} + onRemove={() => removeAt(i)} + /> + )} +
+ ))} + + + {/* Sticky delete zone — sticks to bottom of scroll area while dragging */} + {isDraggingToPool && ( +
{ e.preventDefault(); e.stopPropagation(); setPoolOver(true); setActiveDrop(null); }} + onDragLeave={e => { + if (!(e.currentTarget as Element).contains(e.relatedTarget as Node)) setPoolOver(false); + }} + onDrop={e => { + e.preventDefault(); e.stopPropagation(); + if (drag?.from === "active") removeAt(drag.idx); + else if (drag?.from === "child") removeFromGroup(drag.groupIdx, drag.childIdx); + clearDrag(); + }} + > + {poolOver ? `⬇ ${t("toolbar_customization.release_to_remove_from_toolbar")}` : `🗑 ${t("toolbar_customization.drag_to_remove_from_toolbar")}`} +
+ )} +
+ + {/* ── Add buttons ── */} +
+ + +
+ + {/* ── Section header: Available ── */} + + + {/* ── Pool chips — always rendered so active items can be dragged here to remove ── */} +
{ e.preventDefault(); setPoolOver(true); }} + onDragLeave={e => { + // Only clear when truly leaving the container (not entering a child) + if (!(e.currentTarget as Element).contains(e.relatedTarget as Node)) { + setPoolOver(false); + } + }} + onDrop={onPoolDrop} + > + {/* Remove hint — shown while dragging an active item */} + {isDraggingToPool && ( + + {poolOver ? `⬇ ${t("toolbar_customization.release_to_remove")}` : t("toolbar_customization.drop_to_remove")} + + )} + + {/* "All in toolbar" notice when pool is empty and not dragging */} + {!isDraggingToPool && pool.length === 0 && ( + + {t("toolbar_customization.all_active")} + + )} + + {pool.map(id => ( + appendItem(id)} + onDragStart={e => startDrag(e as DragEvent, { from: "pool", id })} + onDragEnd={clearDrag} + /> + ))} +
+ + {pool.length > 0 && !isDraggingToPool && ( +

+ {t("toolbar_customization.pool_hint")} +

+ )} +
+ ); +} + +// ─── Row components ─────────────────────────────────────────────────────────── + +interface RowBase { + faded: boolean; + indent?: boolean; + onDragStart: (e: Event) => void; + onDragEnd: () => void; + onDragOver: (e: Event) => void; + onDrop: (e: Event) => void; + onRemove: () => void; +} + +const ROW_H = "32px"; + +function rowBase(faded: boolean, extra?: preact.JSX.CSSProperties): preact.JSX.CSSProperties { + return { + display: "flex", + alignItems: "center", + height: ROW_H, + padding: "0 8px", + cursor: "grab", + opacity: faded ? 0.3 : 1, + userSelect: "none", + color: "var(--bs-body-color)", // explicit: prevents dark-mode white-on-light issue + borderBottom: `1px solid ${COLOR.rowSep}`, + transition: "background .1s, opacity .1s", + ...extra, + }; +} + +function ItemRow({ id, faded, indent, onDragStart, onDragEnd, onDragOver, onDrop, onRemove }: RowBase & { id: string }) { + return ( +
(e.currentTarget as HTMLElement).style.boxShadow = `inset 0 0 0 999px ${COLOR.hoverOverlay}`} + onMouseLeave={e => (e.currentTarget as HTMLElement).style.boxShadow = ""} + > + + + + + + {getItemLabel(id)} + + +
+ ); +} + +function SepRow({ faded, indent, onDragStart, onDragEnd, onDragOver, onDrop, onRemove }: RowBase) { + return ( +
(e.currentTarget as HTMLElement).style.boxShadow = `inset 0 0 0 999px ${COLOR.hoverOverlay}`} + onMouseLeave={e => (e.currentTarget as HTMLElement).style.boxShadow = ""} + > + + {/* currentColor adapts to --bs-body-color in any theme; opacity dims it slightly */} +
+
+ + │ {t("toolbar_customization.separator")} + +
+
+ +
+ ); +} + +interface GroupRowProps extends RowBase { + group: ToolbarGroup; + expanded: boolean; + onToggle: () => void; +} + +function GroupRow({ group, faded, expanded, onToggle, onDragStart, onDragEnd, onDragOver, onDrop, onRemove }: GroupRowProps) { + return ( +
+ + ··· + + {group.label} + + + ({group.items.length}) + + + +
+ ); +} + +function PoolChip({ id, onAdd, onDragStart, onDragEnd }: { + id: string; + onAdd: () => void; + onDragStart: (e: Event) => void; + onDragEnd: () => void; +}) { + return ( + + ); +} + +// ─── Icon renderer ──────────────────────────────────────────────────────────── + +function ToolbarIcon({ id, size }: { id: string; size: number }) { + const svg = SVG_ICON[id]; + if (svg) return ( + + ); + const bx = BX_ICON[id]; + if (bx) return ; + const { char, css } = textFallback(id); + return ( + + {char} + + ); +} + +// ─── Micro helpers ──────────────────────────────────────────────────────────── + +/** Renders a reliable 2×3 dot grid drag handle using inline SVG. */ +function DragDots() { + return ( + + ); +} + +function RemoveBtn({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +function SectionHeader({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +/** + * Drop indicator between rows. + * Renders nothing (height 0) when inactive so rows stay flush. + * When active: a solid 3px blue line with a bright glow so it's unmissable in any theme. + */ +function DropLine({ active, indent }: { active: boolean; indent?: boolean }) { + if (!active) return
; + return ( +
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/text/toolbar.ts b/apps/client/src/widgets/type_widgets/text/toolbar.ts index ae008d43deb..741b8011915 100644 --- a/apps/client/src/widgets/type_widgets/text/toolbar.ts +++ b/apps/client/src/widgets/type_widgets/text/toolbar.ts @@ -1,11 +1,12 @@ -import utils from "../../../services/utils.js"; import options from "../../../services/options.js"; -import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw"; - -const TEXT_FORMATTING_GROUP = { - label: "Text formatting", - icon: "text" -}; +import utils from "../../../services/utils.js"; +import { + DEFAULT_BLOCK_TOOLBAR, + DEFAULT_CLASSIC_TOOLBAR, + DEFAULT_FLOATING_TOOLBAR, + entriesToCKItems, + type ToolbarCustomConfig +} from "./toolbar_config.js"; export function buildToolbarConfig(isClassicToolbar: boolean) { if (utils.isMobile()) { @@ -24,11 +25,11 @@ export function buildMobileToolbar() { for (const item of classicConfig.toolbar.items) { if (typeof item === "object" && "items" in item) { - for (const subitem of item.items) { + for (const subitem of (item as { items: string[] }).items) { items.push(subitem); } } else { - items.push(item); + items.push(item as string); } } @@ -45,48 +46,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) { // For nested toolbars, refer to https://ckeditor.com/docs/ckeditor5/latest/getting-started/setup/toolbar.html#grouping-toolbar-items-in-dropdowns-nested-toolbars. return { toolbar: { - items: [ - "heading", - "fontSize", - "|", - "bold", - "italic", - { - ...TEXT_FORMATTING_GROUP, - items: ["underline", "strikethrough", "|", "superscript", "subscript", "|", "kbd"] - }, - "formatPainter", - "|", - "fontColor", - "fontBackgroundColor", - "removeFormat", - "|", - "bulletedList", - "numberedList", - "todoList", - "|", - "blockQuote", - "admonition", - "insertTable", - "|", - "code", - "codeBlock", - "|", - "footnote", - { - label: "Insert", - icon: "plus", - items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"] - }, - "|", - buildAlignmentToolbar(), - "outdent", - "indent", - "|", - "insertTemplate", - "markdownImport", - "cuttonote" - ], + items: resolveClassicItems(), shouldNotGroupWhenFull: multilineToolbar } }; @@ -95,64 +55,42 @@ export function buildClassicToolbar(multilineToolbar: boolean) { export function buildFloatingToolbar() { return { toolbar: { - items: [ - "fontSize", - "bold", - "italic", - "underline", - { - ...TEXT_FORMATTING_GROUP, - items: [ "strikethrough", "|", "superscript", "subscript", "|", "kbd" ] - }, - "formatPainter", - "|", - "fontColor", - "fontBackgroundColor", - "|", - "code", - "link", - "bookmark", - "removeFormat", - "internallink", - "cuttonote" - ] + items: resolveFloatingItems() }, - blockToolbar: [ - "heading", - "|", - "bulletedList", - "numberedList", - "todoList", - "|", - "blockQuote", - "admonition", - "codeBlock", - "insertTable", - "footnote", - { - label: "Insert", - icon: "plus", - items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"] - }, - "|", - buildAlignmentToolbar(), - "outdent", - "indent", - "|", - "insertTemplate", - "imageUpload", - "markdownImport", - "specialCharacters", - "emoji" - ] + blockToolbar: resolveBlockToolbarItems() }; } -function buildAlignmentToolbar() { - return { - label: "Alignment", - icon: IconAlignCenter, - items: ["alignment:left", "alignment:center", "alignment:right", "|", "alignment:justify"] - }; +// ─── Private helpers ────────────────────────────────────────────────────────── + +/** + * Parse the stored toolbar config option. + * Returns null when no custom config is set (empty string or invalid JSON). + */ +function parseStoredConfig(): ToolbarCustomConfig | null { + const raw = options.get("textNoteToolbarConfig"); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as ToolbarCustomConfig; + } catch { + return null; + } +} + +function resolveClassicItems(): (string | object)[] { + const cfg = parseStoredConfig(); + return entriesToCKItems(cfg?.classic ?? DEFAULT_CLASSIC_TOOLBAR); +} + +function resolveFloatingItems(): (string | object)[] { + const cfg = parseStoredConfig(); + return entriesToCKItems(cfg?.floating ?? DEFAULT_FLOATING_TOOLBAR); +} + +function resolveBlockToolbarItems(): (string | object)[] { + const cfg = parseStoredConfig(); + return entriesToCKItems(cfg?.blockToolbar ?? DEFAULT_BLOCK_TOOLBAR); } diff --git a/apps/client/src/widgets/type_widgets/text/toolbar_config.ts b/apps/client/src/widgets/type_widgets/text/toolbar_config.ts new file mode 100644 index 00000000000..80c9b350464 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/text/toolbar_config.ts @@ -0,0 +1,341 @@ +/** + * Toolbar customization types, defaults, and CKEditor5 conversion utilities. + * + * The stored config is a JSON string in the `textNoteToolbarConfig` option. + * An empty string means "use built-in defaults", so existing installations + * are unaffected when this feature is first deployed. + */ +import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw"; + +// ─── Data model ───────────────────────────────────────────────────────────── + +/** A regular toolbar button/command (e.g. "bold", "insertTable"). */ +export interface ToolbarItem { + kind: "item"; + id: string; + visible: boolean; +} + +/** A visual separator rendered as a vertical bar "|" between toolbar sections. */ +export interface ToolbarSeparator { + kind: "separator"; +} + +/** + * A dropdown group rendered as a labelled button that reveals child items. + * Mirrors the CKEditor5 nested-toolbar format (see upstream docs). + */ +export interface ToolbarGroup { + kind: "group"; + /** Stable identifier used for React keys and move-into-group logic. */ + id: string; + label: string; + /** Built-in icon name ("text", "plus", "threeVerticalDots") or the + * sentinel "__alignCenter__" that is resolved to the SVG at build time. */ + icon: string; + visible: boolean; + items: Array; +} + +export type ToolbarEntry = ToolbarItem | ToolbarSeparator | ToolbarGroup; + +export interface ToolbarCustomConfig { + version: 1; + /** Fixed toolbar rendered at the top (classic / "ckeditor-classic" mode). */ + classic: ToolbarEntry[]; + /** Inline toolbar that pops up near the cursor (balloon / "ckeditor-balloon" mode). */ + floating: ToolbarEntry[]; + /** Block-level toolbar shown at the left margin in balloon mode. */ + blockToolbar: ToolbarEntry[]; +} + +// ─── Icon resolution ───────────────────────────────────────────────────────── + +const ALIGN_CENTER_ICON_KEY = "__alignCenter__"; + +function resolveIcon(icon: string): string { + return icon === ALIGN_CENTER_ICON_KEY ? IconAlignCenter : icon; +} + +// ─── CKEditor5 conversion ──────────────────────────────────────────────────── + +/** Remove leading, trailing, and consecutive separators from a CKEditor item list. */ +function cleanupSeparators(items: (string | object)[]): (string | object)[] { + const result: (string | object)[] = []; + for (const item of items) { + if (item === "|") { + if (result.length === 0 || result[result.length - 1] === "|") { + continue; + } + result.push(item); + } else { + result.push(item); + } + } + while (result.length > 0 && result[result.length - 1] === "|") { + result.pop(); + } + return result; +} + +/** + * Convert our toolbar config entries into the array format expected by CKEditor5. + * Hidden items and empty groups are omitted; separators are cleaned up afterwards. + */ +export function entriesToCKItems(entries: ToolbarEntry[]): (string | object)[] { + const raw: (string | object)[] = []; + + for (const entry of entries) { + if (entry.kind === "separator") { + raw.push("|"); + } else if (entry.kind === "group") { + if (!entry.visible) { + continue; + } + const childItems = entriesToCKItems(entry.items); + if (childItems.length === 0) { + continue; + } + raw.push({ + label: entry.label, + icon: resolveIcon(entry.icon), + items: childItems + }); + } else { + if (entry.visible) { + raw.push(entry.id); + } + } + } + + return cleanupSeparators(raw); +} + +// ─── Human-readable labels ─────────────────────────────────────────────────── + +/** Maps CKEditor command names to display labels shown in the settings UI. */ +export const TOOLBAR_ITEM_LABELS: Record = { + "heading": "Heading", + "fontSize": "Font Size", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "superscript": "Superscript", + "subscript": "Subscript", + "kbd": "Keyboard Input", + "formatPainter": "Format Painter", + "fontColor": "Font Color", + "fontBackgroundColor": "Background Color", + "removeFormat": "Remove Formatting", + "bulletedList": "Bulleted List", + "numberedList": "Numbered List", + "todoList": "To-Do List", + "blockQuote": "Block Quote", + "admonition": "Admonition", + "insertTable": "Insert Table", + "code": "Inline Code", + "codeBlock": "Code Block", + "footnote": "Footnote", + "imageUpload": "Upload Image", + "link": "Link", + "bookmark": "Bookmark", + "internallink": "Internal Link", + "includeNote": "Include Note", + "specialCharacters": "Special Characters", + "emoji": "Emoji", + "math": "Math Formula", + "mermaid": "Mermaid Diagram", + "horizontalLine": "Horizontal Line", + "pageBreak": "Page Break", + "dateTime": "Date & Time", + "alignment:left": "Align Left", + "alignment:center": "Align Center", + "alignment:right": "Align Right", + "alignment:justify": "Justify", + "outdent": "Decrease Indent", + "indent": "Increase Indent", + "insertTemplate": "Insert Template", + "markdownImport": "Import from Markdown", + "cuttonote": "Cut to Note" +}; + +export function getItemLabel(id: string): string { + return TOOLBAR_ITEM_LABELS[id] ?? id; +} + +// ─── Default configurations ────────────────────────────────────────────────── + +/** + * Default classic-toolbar configuration. + * Must produce an identical result to the previous hardcoded buildClassicToolbar() + * when all items are visible, so that existing behaviour is unchanged. + */ +export const DEFAULT_CLASSIC_TOOLBAR: ToolbarEntry[] = [ + { kind: "item", id: "heading", visible: true }, + { kind: "item", id: "fontSize", visible: true }, + { kind: "separator" }, + { kind: "item", id: "bold", visible: true }, + { kind: "item", id: "italic", visible: true }, + { + kind: "group", id: "textFormatting", + label: "Text formatting", icon: "text", visible: true, + items: [ + { kind: "item", id: "underline", visible: true }, + { kind: "item", id: "strikethrough", visible: true }, + { kind: "separator" }, + { kind: "item", id: "superscript", visible: true }, + { kind: "item", id: "subscript", visible: true }, + { kind: "separator" }, + { kind: "item", id: "kbd", visible: true } + ] + }, + { kind: "item", id: "formatPainter", visible: true }, + { kind: "separator" }, + { kind: "item", id: "fontColor", visible: true }, + { kind: "item", id: "fontBackgroundColor", visible: true }, + { kind: "item", id: "removeFormat", visible: true }, + { kind: "separator" }, + { kind: "item", id: "bulletedList", visible: true }, + { kind: "item", id: "numberedList", visible: true }, + { kind: "item", id: "todoList", visible: true }, + { kind: "separator" }, + { kind: "item", id: "blockQuote", visible: true }, + { kind: "item", id: "admonition", visible: true }, + { kind: "item", id: "insertTable", visible: true }, + { kind: "separator" }, + { kind: "item", id: "code", visible: true }, + { kind: "item", id: "codeBlock", visible: true }, + { kind: "separator" }, + { kind: "item", id: "footnote", visible: true }, + { + kind: "group", id: "insert", + label: "Insert", icon: "plus", visible: true, + items: [ + { kind: "item", id: "imageUpload", visible: true }, + { kind: "separator" }, + { kind: "item", id: "link", visible: true }, + { kind: "item", id: "bookmark", visible: true }, + { kind: "item", id: "internallink", visible: true }, + { kind: "item", id: "includeNote", visible: true }, + { kind: "separator" }, + { kind: "item", id: "specialCharacters", visible: true }, + { kind: "item", id: "emoji", visible: true }, + { kind: "item", id: "math", visible: true }, + { kind: "item", id: "mermaid", visible: true }, + { kind: "item", id: "horizontalLine", visible: true }, + { kind: "item", id: "pageBreak", visible: true }, + { kind: "item", id: "dateTime", visible: true } + ] + }, + { kind: "separator" }, + { + kind: "group", id: "alignment", + label: "Alignment", icon: ALIGN_CENTER_ICON_KEY, visible: true, + items: [ + { kind: "item", id: "alignment:left", visible: true }, + { kind: "item", id: "alignment:center", visible: true }, + { kind: "item", id: "alignment:right", visible: true }, + { kind: "separator" }, + { kind: "item", id: "alignment:justify", visible: true } + ] + }, + { kind: "item", id: "outdent", visible: true }, + { kind: "item", id: "indent", visible: true }, + { kind: "separator" }, + { kind: "item", id: "insertTemplate", visible: true }, + { kind: "item", id: "markdownImport", visible: true }, + { kind: "item", id: "cuttonote", visible: true } +]; + +/** Default floating-toolbar configuration (balloon mode inline toolbar). */ +export const DEFAULT_FLOATING_TOOLBAR: ToolbarEntry[] = [ + { kind: "item", id: "fontSize", visible: true }, + { kind: "item", id: "bold", visible: true }, + { kind: "item", id: "italic", visible: true }, + { kind: "item", id: "underline", visible: true }, + { + kind: "group", id: "textFormatting", + label: "Text formatting", icon: "text", visible: true, + items: [ + { kind: "item", id: "strikethrough", visible: true }, + { kind: "separator" }, + { kind: "item", id: "superscript", visible: true }, + { kind: "item", id: "subscript", visible: true }, + { kind: "separator" }, + { kind: "item", id: "kbd", visible: true } + ] + }, + { kind: "item", id: "formatPainter", visible: true }, + { kind: "separator" }, + { kind: "item", id: "fontColor", visible: true }, + { kind: "item", id: "fontBackgroundColor", visible: true }, + { kind: "separator" }, + { kind: "item", id: "code", visible: true }, + { kind: "item", id: "link", visible: true }, + { kind: "item", id: "bookmark", visible: true }, + { kind: "item", id: "removeFormat", visible: true }, + { kind: "item", id: "internallink", visible: true }, + { kind: "item", id: "cuttonote", visible: true } +]; + +/** Default block-toolbar configuration (balloon mode block toolbar). */ +export const DEFAULT_BLOCK_TOOLBAR: ToolbarEntry[] = [ + { kind: "item", id: "heading", visible: true }, + { kind: "separator" }, + { kind: "item", id: "bulletedList", visible: true }, + { kind: "item", id: "numberedList", visible: true }, + { kind: "item", id: "todoList", visible: true }, + { kind: "separator" }, + { kind: "item", id: "blockQuote", visible: true }, + { kind: "item", id: "admonition", visible: true }, + { kind: "item", id: "codeBlock", visible: true }, + { kind: "item", id: "insertTable", visible: true }, + { kind: "item", id: "footnote", visible: true }, + { + kind: "group", id: "insert", + label: "Insert", icon: "plus", visible: true, + items: [ + { kind: "item", id: "link", visible: true }, + { kind: "item", id: "bookmark", visible: true }, + { kind: "item", id: "internallink", visible: true }, + { kind: "item", id: "includeNote", visible: true }, + { kind: "separator" }, + { kind: "item", id: "math", visible: true }, + { kind: "item", id: "mermaid", visible: true }, + { kind: "item", id: "horizontalLine", visible: true }, + { kind: "item", id: "pageBreak", visible: true }, + { kind: "item", id: "dateTime", visible: true } + ] + }, + { kind: "separator" }, + { + kind: "group", id: "alignment", + label: "Alignment", icon: ALIGN_CENTER_ICON_KEY, visible: true, + items: [ + { kind: "item", id: "alignment:left", visible: true }, + { kind: "item", id: "alignment:center", visible: true }, + { kind: "item", id: "alignment:right", visible: true }, + { kind: "separator" }, + { kind: "item", id: "alignment:justify", visible: true } + ] + }, + { kind: "item", id: "outdent", visible: true }, + { kind: "item", id: "indent", visible: true }, + { kind: "separator" }, + { kind: "item", id: "insertTemplate", visible: true }, + { kind: "item", id: "imageUpload", visible: true }, + { kind: "item", id: "markdownImport", visible: true }, + { kind: "item", id: "specialCharacters", visible: true }, + { kind: "item", id: "emoji", visible: true } +]; + +/** Returns a deep copy of the default configuration. */ +export function getDefaultConfig(): ToolbarCustomConfig { + return { + version: 1, + classic: JSON.parse(JSON.stringify(DEFAULT_CLASSIC_TOOLBAR)) as ToolbarEntry[], + floating: JSON.parse(JSON.stringify(DEFAULT_FLOATING_TOOLBAR)) as ToolbarEntry[], + blockToolbar: JSON.parse(JSON.stringify(DEFAULT_BLOCK_TOOLBAR)) as ToolbarEntry[] + }; +} diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index 6234321676c..ab16b19014e 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -94,6 +94,7 @@ const ALLOWED_OPTIONS = new Set([ "textNoteEmojiCompletionEnabled", "textNoteCompletionEnabled", "textNoteSlashCommandsEnabled", + "textNoteToolbarConfig", "layoutOrientation", "backgroundEffects", "allowedHtmlTags", diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index a49672019d1..25f18ce74c8 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -188,6 +188,8 @@ const defaultOptions: DefaultOption[] = [ { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, { name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true }, + // Empty string = use built-in defaults; JSON string = custom toolbar configuration + { name: "textNoteToolbarConfig", value: "", isSynced: true }, // HTML import configuration { name: "layoutOrientation", value: "vertical", isSynced: false }, diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 5582df79d2c..4c7c2929ba1 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -131,6 +131,8 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions