diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 2e228a98256..07e58e572d6 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1933,6 +1933,32 @@ "continue_anyway": "Continue Anyway", "dont_show_again": "Don't show this warning again" }, + "toolbar_customization": { + "title": "Toolbar items", + "description": "Customize the classic editor toolbar. Drag items to reorder, click available items to add them, or drag to the trash zone to remove.", + "current_toolbar": "Current toolbar", + "available_items": "Available items — click to add", + "add_to_group": "Click an item to add it to the group", + "all_used": "All items are already in the toolbar", + "click_to_add": "Click to add to toolbar", + "add_separator": "Add separator", + "separator_hint": "Adds a visual divider between toolbar items", + "add_group": "Add group", + "new_group_label": "Group", + "group_name": "Group name", + "group_empty": "No items — click an available item to add", + "expand_group": "Edit group items", + "collapse_group": "Collapse group", + "remove_item": "Remove", + "drop_to_remove": "Drop here to remove", + "empty": "No items — click an available item to add", + "reset_default": "Reset to default", + "unsaved_changes": "Unsaved changes — click Save & Apply to reload the editor", + "no_changes": "No unsaved changes", + "discard": "Discard", + "save_apply": "Save & Apply", + "saving": "Saving…" + }, "editorfeatures": { "title": "Features", "emoji_completion_enabled": "Enable Emoji auto-completion", 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.css b/apps/client/src/widgets/type_widgets/options/toolbar_customization.css new file mode 100644 index 00000000000..10561b50906 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/toolbar_customization.css @@ -0,0 +1,309 @@ +.toolbar-editor { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* ── Two-column layout ────────────────────────────────────────────────── */ + +.toolbar-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: start; +} + +@media (max-width: 700px) { + .toolbar-columns { + grid-template-columns: 1fr; + } +} + +/* ── Panel (shared by both columns) ──────────────────────────────────── */ + +.toolbar-panel { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid var(--main-border-color); + border-radius: var(--bs-border-radius); + overflow: hidden; +} + +.toolbar-panel-header { + padding: 5px 10px; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--accented-background-color); + border-bottom: 1px solid var(--main-border-color); + color: var(--muted-text-color); +} + +/* ── Vertical item list ───────────────────────────────────────────────── */ + +.toolbar-list { + display: flex; + flex-direction: column; + min-height: 60px; +} + +.toolbar-list.available-list { + max-height: 360px; + overflow-y: auto; +} + +/* ── A single row ─────────────────────────────────────────────────────── */ + +.toolbar-row-wrapper { + display: flex; + flex-direction: column; +} + +.toolbar-row-wrapper.drop-above > .toolbar-row:first-child { + border-top: 2px solid var(--main-text-color); +} + +.toolbar-row { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + font-size: 0.85rem; + border-bottom: 1px solid var(--main-border-color); + user-select: none; + min-height: 32px; + cursor: default; +} + +.toolbar-row:last-child { + border-bottom: none; +} + +.toolbar-row[draggable="true"] { + cursor: grab; +} + +.toolbar-row[draggable="true"]:active { + cursor: grabbing; +} + +/* Group header row */ +.toolbar-row.group-header { + background: var(--accented-background-color); + font-weight: 600; + transition: background 0.12s, outline 0.12s; +} + +/* Drop-into-group highlight: shown when dragging an item over a group header */ +.toolbar-row.group-header.drop-into-group { + outline: 2px solid var(--main-text-color); + outline-offset: -2px; + background: var(--hover-item-background-color); +} + +/* Separator row */ +.toolbar-row.separator { + color: var(--muted-text-color); + font-size: 0.78rem; + font-style: italic; + justify-content: center; + padding-block: 3px; +} + +/* Sub-item row (inside an expanded group) */ +.toolbar-row.sub { + padding-left: 28px; + background: var(--more-accented-background-color); + font-size: 0.82rem; +} + +.toolbar-row.sub.drop-above { + border-top: 2px solid var(--main-text-color); +} + +/* Available-item row */ +.toolbar-row.available { + cursor: pointer; +} + +.toolbar-row.available:hover { + background: var(--hover-item-background-color); +} + +/* ── Row internals ────────────────────────────────────────────────────── */ + +.drag-handle { + color: var(--muted-text-color); + font-size: 1rem; + flex-shrink: 0; + cursor: grab; +} + +.row-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; +} + +.row-add-icon { + color: var(--muted-text-color); + font-size: 1rem; + flex-shrink: 0; +} + +.toolbar-row.available:hover .row-add-icon { + color: var(--main-text-color); +} + +.row-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + padding: 2px; + cursor: pointer; + color: var(--muted-text-color); + line-height: 1; + font-size: 1rem; + border-radius: 2px; + flex-shrink: 0; +} + +.row-btn:hover { + color: var(--main-text-color); + background: var(--hover-item-background-color); +} + +.row-btn.remove:hover { + color: var(--danger-text-color, #dc3545); +} + +/* ── Expanded group panel ─────────────────────────────────────────────── */ + +.group-expanded { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--main-border-color); +} + +.group-name-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 28px; + background: var(--more-accented-background-color); + border-bottom: 1px solid var(--main-border-color); +} + +.group-name-label { + font-size: 0.78rem; + font-weight: 500; + color: var(--muted-text-color); + white-space: nowrap; + flex-shrink: 0; +} + +.group-name-input { + flex: 1; + max-width: 180px; +} + +/* ── Trash zone ───────────────────────────────────────────────────────── */ + +.toolbar-trash { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 10px; + border-top: 1px dashed var(--main-border-color); + color: var(--muted-text-color); + font-size: 0.82rem; + transition: background 0.12s, color 0.12s; +} + +.toolbar-trash.active { + background: rgba(220, 53, 69, 0.12); + background: color-mix(in srgb, var(--danger-text-color, #dc3545) 12%, transparent); + color: var(--danger-text-color, #dc3545); + border-color: var(--danger-text-color, #dc3545); +} + +/* ── Action bar ───────────────────────────────────────────────────────── */ + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + padding: 6px 8px; + border-top: 1px solid var(--main-border-color); + background: var(--accented-background-color); +} + +/* ── Save / Discard bar ───────────────────────────────────────────────── */ + +.toolbar-save-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--main-border-color); + border-radius: var(--bs-border-radius); + background: var(--accented-background-color); + font-size: 0.82rem; + margin-bottom: 8px; +} + +.toolbar-save-bar.has-changes { + border-color: var(--bs-warning, #ffc107); + background: rgba(255, 193, 7, 0.08); + background: color-mix(in srgb, var(--bs-warning, #ffc107) 8%, var(--accented-background-color)); +} + +.toolbar-save-status { + flex: 1; + color: var(--muted-text-color); +} + +.toolbar-save-bar.has-changes .toolbar-save-status { + color: var(--main-text-color); + font-weight: 500; +} + +/* ── Item icons ───────────────────────────────────────────────────────── */ + +.toolbar-item-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted-text-color); +} + +.toolbar-item-icon svg { + width: 16px; + height: 16px; + fill: currentColor; + pointer-events: none; +} + +/* ── Misc ─────────────────────────────────────────────────────────────── */ + +.toolbar-empty-hint { + font-size: 0.8rem; + color: var(--muted-text-color); + padding: 8px 10px; + font-style: italic; +} + +.toolbar-empty-hint.indented { + padding-left: 28px; +} 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..4a189cfa942 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/toolbar_customization.tsx @@ -0,0 +1,721 @@ +import "./toolbar_customization.css"; + +import { useRef, useState } from "preact/hooks"; + +import IconAlignCenter from "@ckeditor/ckeditor5-icons/theme/icons/align-center.svg?raw"; +import IconAlignJustify from "@ckeditor/ckeditor5-icons/theme/icons/align-justify.svg?raw"; +import IconAlignLeft from "@ckeditor/ckeditor5-icons/theme/icons/align-left.svg?raw"; +import IconAlignRight from "@ckeditor/ckeditor5-icons/theme/icons/align-right.svg?raw"; +import IconBold from "@ckeditor/ckeditor5-icons/theme/icons/bold.svg?raw"; +import IconBookmark from "@ckeditor/ckeditor5-icons/theme/icons/bookmark.svg?raw"; +import IconBulletedList from "@ckeditor/ckeditor5-icons/theme/icons/bulleted-list.svg?raw"; +import IconCode from "@ckeditor/ckeditor5-icons/theme/icons/code.svg?raw"; +import IconCodeBlock from "@ckeditor/ckeditor5-icons/theme/icons/code-block.svg?raw"; +import IconEmoji from "@ckeditor/ckeditor5-icons/theme/icons/emoji.svg?raw"; +import IconFontBackground from "@ckeditor/ckeditor5-icons/theme/icons/font-background.svg?raw"; +import IconFontColor from "@ckeditor/ckeditor5-icons/theme/icons/font-color.svg?raw"; +import IconFontSize from "@ckeditor/ckeditor5-icons/theme/icons/font-size.svg?raw"; +import IconFootnote from "@ckeditor/ckeditor5-icons/theme/icons/footnote.svg?raw"; +import IconHeading from "@ckeditor/ckeditor5-icons/theme/icons/heading1.svg?raw"; +import IconHorizontalLine from "@ckeditor/ckeditor5-icons/theme/icons/horizontal-line.svg?raw"; +import IconImageUpload from "@ckeditor/ckeditor5-icons/theme/icons/image-upload.svg?raw"; +import IconIndent from "@ckeditor/ckeditor5-icons/theme/icons/indent.svg?raw"; +import IconItalic from "@ckeditor/ckeditor5-icons/theme/icons/italic.svg?raw"; +import IconLink from "@ckeditor/ckeditor5-icons/theme/icons/link.svg?raw"; +import IconNumberedList from "@ckeditor/ckeditor5-icons/theme/icons/numbered-list.svg?raw"; +import IconOutdent from "@ckeditor/ckeditor5-icons/theme/icons/outdent.svg?raw"; +import IconPageBreak from "@ckeditor/ckeditor5-icons/theme/icons/page-break.svg?raw"; +import IconFormatPainter from "@ckeditor/ckeditor5-icons/theme/icons/paint-roller.svg?raw"; +import IconParagraph from "@ckeditor/ckeditor5-icons/theme/icons/paragraph.svg?raw"; +import IconBlockQuote from "@ckeditor/ckeditor5-icons/theme/icons/quote.svg?raw"; +import IconRemoveFormat from "@ckeditor/ckeditor5-icons/theme/icons/remove-format.svg?raw"; +import IconSpecialCharacters from "@ckeditor/ckeditor5-icons/theme/icons/special-characters.svg?raw"; +import IconStrikethrough from "@ckeditor/ckeditor5-icons/theme/icons/strikethrough.svg?raw"; +import IconSubscript from "@ckeditor/ckeditor5-icons/theme/icons/subscript.svg?raw"; +import IconSuperscript from "@ckeditor/ckeditor5-icons/theme/icons/superscript.svg?raw"; +import IconTable from "@ckeditor/ckeditor5-icons/theme/icons/table.svg?raw"; +import IconTemplate from "@ckeditor/ckeditor5-icons/theme/icons/template.svg?raw"; +import IconTodoList from "@ckeditor/ckeditor5-icons/theme/icons/todo-list.svg?raw"; +import IconUnderline from "@ckeditor/ckeditor5-icons/theme/icons/underline.svg?raw"; +import IconDateTime from "@triliumnext/ckeditor5/src/icons/date-time.svg?raw"; +import IconInternalLink from "@triliumnext/ckeditor5/src/icons/trilium.svg?raw"; +import IconCutToNote from "@triliumnext/ckeditor5/src/icons/scissors.svg?raw"; +import IconIncludeNote from "@triliumnext/ckeditor5/src/icons/note.svg?raw"; +import IconMarkdownImport from "@triliumnext/ckeditor5/src/icons/markdown-mark.svg?raw"; +import IconMath from "../../../../../../packages/ckeditor5-math/theme/icons/math.svg?raw"; +import IconMermaid from "../../../../../../packages/ckeditor5-mermaid/theme/icons/insert.svg?raw"; +import IconKbd from "../../../../../../packages/ckeditor5-keyboard-marker/theme/icons/kbd.svg?raw"; + +import { t } from "../../../services/i18n"; +import { reloadFrontendApp } from "../../../services/utils"; +import { useTriliumOption } from "../../react/hooks"; +import { DEFAULT_CLASSIC_TOOLBAR_ITEMS, type ToolbarGroup, type ToolbarItem } from "../text/toolbar"; +import OptionsSection from "./components/OptionsSection"; + +/** Human-readable labels for all known CKEditor commands. */ +const ITEM_LABELS: Record = { + "heading": "Heading", + "fontSize": "Font Size", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "superscript": "Superscript", + "subscript": "Subscript", + "kbd": "Keyboard", + "formatPainter": "Format Painter", + "fontColor": "Font Color", + "fontBackgroundColor": "Font Background", + "removeFormat": "Remove Format", + "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": "Image Upload", + "link": "Link", + "bookmark": "Bookmark", + "internallink": "Internal Link", + "includeNote": "Include Note", + "specialCharacters": "Special Characters", + "emoji": "Emoji", + "math": "Math", + "mermaid": "Mermaid", + "horizontalLine": "Horizontal Line", + "pageBreak": "Page Break", + "dateTime": "Date/Time", + "alignment:left": "Align Left", + "alignment:center": "Align Center", + "alignment:right": "Align Right", + "alignment:justify": "Align Justify", + "outdent": "Outdent", + "indent": "Indent", + "insertTemplate": "Insert Template", + "markdownImport": "Import Markdown", + "cuttonote": "Cut to Note", +}; + +/** SVG icon strings for known CKEditor commands (keyed by command name). */ +const ITEM_ICONS: Record = { + "heading": IconHeading, + "fontSize": IconFontSize, + "bold": IconBold, + "italic": IconItalic, + "underline": IconUnderline, + "strikethrough": IconStrikethrough, + "superscript": IconSuperscript, + "subscript": IconSubscript, + "kbd": IconKbd, + "formatPainter": IconFormatPainter, + "fontColor": IconFontColor, + "fontBackgroundColor": IconFontBackground, + "removeFormat": IconRemoveFormat, + "bulletedList": IconBulletedList, + "numberedList": IconNumberedList, + "todoList": IconTodoList, + "blockQuote": IconBlockQuote, + "insertTable": IconTable, + "code": IconCode, + "codeBlock": IconCodeBlock, + "footnote": IconFootnote, + "imageUpload": IconImageUpload, + "link": IconLink, + "bookmark": IconBookmark, + "internallink": IconInternalLink, + "includeNote": IconIncludeNote, + "specialCharacters": IconSpecialCharacters, + "emoji": IconEmoji, + "math": IconMath, + "mermaid": IconMermaid, + "horizontalLine": IconHorizontalLine, + "pageBreak": IconPageBreak, + "dateTime": IconDateTime, + "alignment:left": IconAlignLeft, + "alignment:center": IconAlignCenter, + "alignment:right": IconAlignRight, + "alignment:justify": IconAlignJustify, + "outdent": IconOutdent, + "indent": IconIndent, + "insertTemplate": IconTemplate, + "markdownImport": IconMarkdownImport, + "cuttonote": IconCutToNote, + "admonition": IconParagraph, +}; + +const ALL_COMMANDS = Object.keys(ITEM_LABELS); + +type DragSrc = + | { kind: "top"; index: number } + | { kind: "group"; groupIdx: number; itemIdx: number }; + +type DropTarget = + | { kind: "top"; index: number } + | { kind: "group-header"; groupIdx: number } + | { kind: "group"; groupIdx: number; index: number } + | { kind: "trash" }; + +function itemLabel(cmd: string): string { + return ITEM_LABELS[cmd] ?? cmd; +} + +function SvgIcon({ svg }: { svg: string }) { + return ; +} + +/** Stable list key for a toolbar item. Commands are unique, so the name alone suffices; + * separators and groups include the index to handle multiples. */ +function itemKey(item: ToolbarItem, idx: number): string { + if (item === "|") return `sep_${idx}`; + if (typeof item === "object") return `grp_${(item as ToolbarGroup).label}_${idx}`; + return item; // command names are guaranteed unique in the toolbar +} + +/** Returns a copy of `group` with `item` inserted at `insertAt`, or appended if undefined. */ +function insertIntoGroup(group: ToolbarGroup, item: string, insertAt: number | undefined): ToolbarGroup { + const sub = [...group.items]; + if (insertAt === undefined) sub.push(item); + else sub.splice(insertAt, 0, item); + return { ...group, items: sub }; +} + +function parseConfig(configStr: string): ToolbarItem[] { + if (!configStr) return [...DEFAULT_CLASSIC_TOOLBAR_ITEMS]; + try { + return JSON.parse(configStr) as ToolbarItem[]; + } catch { + return [...DEFAULT_CLASSIC_TOOLBAR_ITEMS]; + } +} + +function collectUsed(items: ToolbarItem[]): Set { + const used = new Set(); + for (const item of items) { + if (typeof item === "string" && item !== "|") used.add(item); + else if (typeof item === "object") { + for (const sub of (item as ToolbarGroup).items) { + if (sub !== "|") used.add(sub); + } + } + } + return used; +} + +export default function ToolbarCustomization() { + const [configStr, setConfigStr] = useTriliumOption("textNoteToolbarConfig"); + + // Local pending state — only pushed to the server when the user clicks "Save & Apply" + const [pending, setPending] = useState(() => parseConfig(configStr)); + const [saving, setSaving] = useState(false); + + const [expandedGroup, setExpandedGroup] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [dropTarget, setDropTarget] = useState(null); + const dragSrc = useRef(null); + const autoExpandTimer = useRef | null>(null); + + const usedCommands = collectUsed(pending); + const availableCommands = ALL_COMMANDS.filter((cmd) => !usedCommands.has(cmd)); + const hasChanges = JSON.stringify(pending) !== JSON.stringify(parseConfig(configStr)); + + function update(next: ToolbarItem[]) { + setPending(next); + } + + // ── Save / Discard ────────────────────────────────────────────────── + + async function handleSave() { + setSaving(true); + await setConfigStr(JSON.stringify(pending)); + reloadFrontendApp("toolbar configuration changed"); + } + + function handleDiscard() { + setPending(parseConfig(configStr)); + setExpandedGroup(null); + } + + // ── Mutations ─────────────────────────────────────────────────────── + + function removeTopItem(index: number) { + const next = pending.filter((_, i) => i !== index); + if (expandedGroup === index) setExpandedGroup(null); + else if (expandedGroup !== null && expandedGroup > index) setExpandedGroup(expandedGroup - 1); + update(next); + } + + function removeGroupItem(groupIdx: number, itemIdx: number) { + update(pending.map((item, i) => { + if (i !== groupIdx || typeof item !== "object") return item; + return { ...(item as ToolbarGroup), items: (item as ToolbarGroup).items.filter((_, j) => j !== itemIdx) }; + })); + } + + function handleAddItem(cmd: string) { + if (expandedGroup !== null) { + update(pending.map((item, i) => { + if (i !== expandedGroup || typeof item !== "object") return item; + return { ...(item as ToolbarGroup), items: [...(item as ToolbarGroup).items, cmd] }; + })); + } else { + update([...pending, cmd]); + } + } + + function handleAddSeparator() { + if (expandedGroup !== null) { + update(pending.map((item, i) => { + if (i !== expandedGroup || typeof item !== "object") return item; + return { ...(item as ToolbarGroup), items: [...(item as ToolbarGroup).items, "|"] }; + })); + } else { + update([...pending, "|"]); + } + } + + function handleAddGroup() { + const newGroup: ToolbarGroup = { + label: t("toolbar_customization.new_group_label"), + icon: "threeVerticalDots", + items: [] + }; + const next = [...pending, newGroup]; + update(next); + setExpandedGroup(next.length - 1); + } + + function renameGroup(groupIdx: number, label: string) { + update(pending.map((item, i) => { + if (i !== groupIdx || typeof item !== "object") return item; + return { ...(item as ToolbarGroup), label }; + })); + } + + function handleReset() { + setPending([...DEFAULT_CLASSIC_TOOLBAR_ITEMS]); + setExpandedGroup(null); + } + + // ── Auto-expand on hover ──────────────────────────────────────────── + + function scheduleExpand(groupIdx: number) { + if (autoExpandTimer.current !== null) return; + autoExpandTimer.current = setTimeout(() => { + setExpandedGroup(groupIdx); + autoExpandTimer.current = null; + }, 600); + } + + function cancelExpand() { + if (autoExpandTimer.current !== null) { + clearTimeout(autoExpandTimer.current); + autoExpandTimer.current = null; + } + } + + // ── Drag & Drop ───────────────────────────────────────────────────── + + function onDragStart(e: DragEvent, src: DragSrc) { + dragSrc.current = src; + setIsDragging(true); + e.dataTransfer?.setData("text/plain", ""); + } + + function onDragEnd() { + dragSrc.current = null; + setIsDragging(false); + setDropTarget(null); + cancelExpand(); + } + + function onDragOverZone(e: DragEvent, target: DropTarget) { + e.preventDefault(); + e.stopPropagation(); + setDropTarget(target); + + if (target.kind === "group-header") { + // Only allow dropping non-group items into a group + const src = dragSrc.current; + const srcItem = src?.kind === "top" ? pending[src.index] : null; + if (srcItem === null || typeof srcItem === "string") { + scheduleExpand(target.groupIdx); + } + } else { + cancelExpand(); + } + } + + function onDragLeave(e: DragEvent) { + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) { + setDropTarget(null); + } + } + + function onDropZone(e: DragEvent) { + e.preventDefault(); + const src = dragSrc.current; + const target = dropTarget; + dragSrc.current = null; + setIsDragging(false); + setDropTarget(null); + cancelExpand(); + if (!src || !target) return; + + if (target.kind === "trash") { + if (src.kind === "top") removeTopItem(src.index); + else removeGroupItem(src.groupIdx, src.itemIdx); + return; + } + if (src.kind === "top" && target.kind === "top") { + moveTopItem(src.index, target.index); + } else if (src.kind === "top" && target.kind === "group-header") { + moveTopIntoGroup(src.index, target.groupIdx, undefined); + } else if (src.kind === "top" && target.kind === "group") { + moveTopIntoGroup(src.index, target.groupIdx, target.index); + } else if (src.kind === "group" && target.kind === "top") { + moveGroupItemToTop(src.groupIdx, src.itemIdx, target.index); + } else if (src.kind === "group" && target.kind === "group" && src.groupIdx === target.groupIdx) { + moveWithinGroup(src.groupIdx, src.itemIdx, target.index); + } else if (src.kind === "group" && target.kind === "group" && src.groupIdx !== target.groupIdx) { + moveCrossGroup(src.groupIdx, src.itemIdx, target.groupIdx, target.index); + } else if (src.kind === "group" && target.kind === "group-header" && src.groupIdx !== target.groupIdx) { + moveCrossGroup(src.groupIdx, src.itemIdx, target.groupIdx, undefined); + } + } + + function moveTopItem(from: number, to: number) { + if (from === to) return; + const next = [...pending]; + const [moved] = next.splice(from, 1); + const dest = to > from ? to - 1 : to; + next.splice(dest, 0, moved); + if (expandedGroup === from) setExpandedGroup(dest); + else if (expandedGroup !== null) { + if (from < expandedGroup && dest >= expandedGroup) setExpandedGroup(expandedGroup - 1); + else if (from > expandedGroup && dest <= expandedGroup) setExpandedGroup(expandedGroup + 1); + } + update(next); + } + + /** Move a top-level string/separator item into a group. Groups cannot be nested. */ + function moveTopIntoGroup(topIdx: number, groupIdx: number, insertAt: number | undefined) { + const item = pending[topIdx]; + if (typeof item === "object") return; // groups cannot nest + + const withoutItem = pending.filter((_, i) => i !== topIdx); + const adjGroup = topIdx < groupIdx ? groupIdx - 1 : groupIdx; + + const result = withoutItem.map((g, i) => + i === adjGroup && typeof g === "object" ? insertIntoGroup(g as ToolbarGroup, item as string, insertAt) : g + ); + + setExpandedGroup(adjGroup); + update(result); + } + + /** Pull a group sub-item out to the top level. */ + function moveGroupItemToTop(groupIdx: number, itemIdx: number, topIdx: number) { + const item = (pending[groupIdx] as ToolbarGroup).items[itemIdx]; + + const withoutFromGroup = pending.map((g, i) => { + if (i !== groupIdx || typeof g !== "object") return g; + return { ...(g as ToolbarGroup), items: (g as ToolbarGroup).items.filter((_, j) => j !== itemIdx) }; + }); + + const result = [...withoutFromGroup]; + result.splice(topIdx, 0, item); + + // The group may have shifted if we inserted before it + if (expandedGroup !== null) { + setExpandedGroup(topIdx <= groupIdx ? groupIdx + 1 : groupIdx); + } + update(result); + } + + function moveWithinGroup(groupIdx: number, from: number, to: number) { + if (from === to) return; + const group = pending[groupIdx] as ToolbarGroup; + const sub = [...group.items]; + const [moved] = sub.splice(from, 1); + sub.splice(to > from ? to - 1 : to, 0, moved); + update(pending.map((item, i) => i === groupIdx ? { ...(item as ToolbarGroup), items: sub } : item)); + } + + function moveCrossGroup(fromGroupIdx: number, itemIdx: number, toGroupIdx: number, insertAt: number | undefined) { + const item = (pending[fromGroupIdx] as ToolbarGroup).items[itemIdx]; + const result = pending.map((g, i) => { + if (typeof g !== "object") return g; + if (i === fromGroupIdx) return { ...(g as ToolbarGroup), items: (g as ToolbarGroup).items.filter((_, j) => j !== itemIdx) }; + if (i === toGroupIdx) return insertIntoGroup(g as ToolbarGroup, item, insertAt); + return g; + }); + setExpandedGroup(toGroupIdx); + update(result); + } + + // ── Render ────────────────────────────────────────────────────────── + + return ( + +

{t("toolbar_customization.description")}

+ + {/* Save / Discard bar */} +
+ + {hasChanges ? t("toolbar_customization.unsaved_changes") : t("toolbar_customization.no_changes")} + + + +
+ +
+
+ + {/* Left panel: current toolbar */} +
+
{t("toolbar_customization.current_toolbar")}
+
onDragOverZone(e, { kind: "top", index: pending.length })} + onDragLeave={onDragLeave} + onDrop={onDropZone} + > + {pending.length === 0 && ( +
{t("toolbar_customization.empty")}
+ )} + {pending.map((item, idx) => ( + setExpandedGroup(expandedGroup === idx ? null : idx)} + onRemove={() => removeTopItem(idx)} + onRemoveGroupItem={(itemIdx) => removeGroupItem(idx, itemIdx)} + onRenameGroup={(label) => renameGroup(idx, label)} + onDragStart={(e) => onDragStart(e, { kind: "top", index: idx })} + onDragEnd={onDragEnd} + onDragOver={(e: DragEvent) => { e.stopPropagation(); onDragOverZone(e, { kind: "top", index: idx }); }} + onDragOverGroupHeader={(e: DragEvent) => { e.stopPropagation(); onDragOverZone(e, { kind: "group-header", groupIdx: idx }); }} + onDragLeave={onDragLeave} + onDrop={(e: DragEvent) => { e.stopPropagation(); onDropZone(e); }} + onGroupItemDragStart={(itemIdx, e) => onDragStart(e, { kind: "group", groupIdx: idx, itemIdx })} + onGroupItemDragEnd={onDragEnd} + onGroupItemDragOver={(itemIdx, e) => { e.stopPropagation(); onDragOverZone(e, { kind: "group", groupIdx: idx, index: itemIdx }); }} + onGroupItemDrop={(e) => { e.stopPropagation(); onDropZone(e); }} + /> + ))} +
+ + {isDragging && ( +
onDragOverZone(e, { kind: "trash" })} + onDragLeave={onDragLeave} + onDrop={onDropZone} + > + + {" "}{t("toolbar_customization.drop_to_remove")} +
+ )} + +
+ + {expandedGroup === null && ( + + )} + +
+
+ + {/* Right panel: available items */} +
+
+ {expandedGroup !== null + ? t("toolbar_customization.add_to_group") + : t("toolbar_customization.available_items")} +
+
+ {availableCommands.length === 0 && ( +
{t("toolbar_customization.all_used")}
+ )} + {availableCommands.map((cmd) => ( +
handleAddItem(cmd)} + title={t("toolbar_customization.click_to_add")} + > +
+ ))} +
+
+ +
+
+
+ ); +} + +// ─── ToolbarRow sub-component ───────────────────────────────────────────────── + +interface ToolbarRowProps { + item: ToolbarItem; + isExpanded: boolean; + isDropTarget: boolean; + isGroupDropTarget: boolean; + groupDropTargetIdx: number | null; + onToggleExpand: () => void; + onRemove: () => void; + onRemoveGroupItem: (itemIdx: number) => void; + onRenameGroup: (label: string) => void; + onDragStart: (e: DragEvent) => void; + onDragEnd: () => void; + onDragOver: (e: DragEvent) => void; + onDragOverGroupHeader: (e: DragEvent) => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + onGroupItemDragStart: (itemIdx: number, e: DragEvent) => void; + onGroupItemDragEnd: () => void; + onGroupItemDragOver: (itemIdx: number, e: DragEvent) => void; + onGroupItemDrop: (e: DragEvent) => void; +} + +function ToolbarRow({ + item, + isExpanded, + isDropTarget, + isGroupDropTarget, + groupDropTargetIdx, + onToggleExpand, + onRemove, + onRemoveGroupItem, + onRenameGroup, + onDragStart, + onDragEnd, + onDragOver, + onDragOverGroupHeader, + onDragLeave, + onDrop, + onGroupItemDragStart, + onGroupItemDragEnd, + onGroupItemDragOver, + onGroupItemDrop +}: ToolbarRowProps) { + const isGroup = typeof item === "object"; + const isSeparator = item === "|"; + const label = isSeparator ? "── separator ──" + : isGroup ? (item as ToolbarGroup).label + : itemLabel(item as string); + + return ( +
+
+
+ + {isGroup && isExpanded && ( +
+
+ {t("toolbar_customization.group_name")} + onRenameGroup((e.target as HTMLInputElement).value)} + /> +
+ {(item as ToolbarGroup).items.length === 0 && ( +
{t("toolbar_customization.group_empty")}
+ )} + {(item as ToolbarGroup).items.map((sub, subIdx) => ( +
onGroupItemDragStart(subIdx, e as DragEvent)} + onDragEnd={onGroupItemDragEnd} + onDragOver={(e) => { (e as DragEvent).preventDefault(); onGroupItemDragOver(subIdx, e as DragEvent); }} + onDrop={onGroupItemDrop} + > +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/text/toolbar.ts b/apps/client/src/widgets/type_widgets/text/toolbar.ts index ae008d43deb..7cdef9e4d96 100644 --- a/apps/client/src/widgets/type_widgets/text/toolbar.ts +++ b/apps/client/src/widgets/type_widgets/text/toolbar.ts @@ -7,11 +7,61 @@ const TEXT_FORMATTING_GROUP = { icon: "text" }; +export type ToolbarItem = string | ToolbarGroup; + +export interface ToolbarGroup { + label: string; + icon?: string; + items: string[]; +} + +function getCustomToolbarItems(): ToolbarItem[] | null { + const configStr = options.get("textNoteToolbarConfig"); + if (!configStr) return null; + try { + return JSON.parse(configStr) as ToolbarItem[]; + } catch { + return null; + } +} + +function flattenItems(items: ToolbarItem[]): string[] { + const flat: string[] = []; + for (const item of items) { + if (typeof item === "object" && "items" in item) { + for (const sub of item.items) { + flat.push(sub); + } + } else { + flat.push(item as string); + } + } + return flat; +} + export function buildToolbarConfig(isClassicToolbar: boolean) { + const customItems = getCustomToolbarItems(); + if (utils.isMobile()) { + if (customItems) { + return { + toolbar: { + items: flattenItems(customItems), + shouldNotGroupWhenFull: false + } + }; + } return buildMobileToolbar(); } else if (isClassicToolbar) { const multilineToolbar = utils.isDesktop() && options.get("textNoteEditorMultilineToolbar") === "true"; + if (customItems) { + return { + toolbar: { + items: customItems, + shouldNotGroupWhenFull: multilineToolbar + } + }; + } return buildClassicToolbar(multilineToolbar); } else { return buildFloatingToolbar(); @@ -41,6 +91,53 @@ export function buildMobileToolbar() { }; } +export const DEFAULT_CLASSIC_TOOLBAR_ITEMS: ToolbarItem[] = [ + "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"] + }, + "|", + { + label: "Alignment", + icon: "alignLeft", + items: ["alignment:left", "alignment:center", "alignment:right", "|", "alignment:justify"] + }, + "outdent", + "indent", + "|", + "insertTemplate", + "markdownImport", + "cuttonote" +]; + 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 { diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index bb6ffb00d69..ddfe1ff0f4e 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -91,6 +91,7 @@ const ALLOWED_OPTIONS = new Set([ "languages", "textNoteEditorType", "textNoteEditorMultilineToolbar", + "textNoteToolbarConfig", "textNoteEmojiCompletionEnabled", "textNoteCompletionEnabled", "textNoteSlashCommandsEnabled", diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index a49672019d1..764239a1b4f 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -185,6 +185,7 @@ const defaultOptions: DefaultOption[] = [ // Text note configuration { name: "textNoteEditorType", value: "ckeditor-balloon", isSynced: true }, { name: "textNoteEditorMultilineToolbar", value: "false", isSynced: true }, + { name: "textNoteToolbarConfig", value: "", isSynced: true }, { name: "textNoteEmojiCompletionEnabled", value: "true", isSynced: true }, { name: "textNoteCompletionEnabled", value: "true", isSynced: true }, { name: "textNoteSlashCommandsEnabled", value: "true", isSynced: true }, diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 5582df79d2c..82dea2831db 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