diff --git a/src/App.svelte b/src/App.svelte index f8a5c2a..be4e0e6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -138,7 +138,12 @@ class:selected={themeMode === m} onclick={() => selectTheme(m)} role="menuitem" - >{label} + > + {label} + {#if m === 'allBlack'} + font colors forced white + {/if} + {/each} {/if} @@ -259,7 +264,10 @@ } .theme-option { - display: block; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; width: 100%; padding: 0.5rem 0.75rem; border: none; @@ -281,6 +289,13 @@ font-weight: 600; } + .theme-option-hint { + font-size: 0.7rem; + color: var(--color-text-muted); + font-style: italic; + font-weight: 400; + } + .export-btn { margin-left: 0; margin-right: 0; diff --git a/src/lib/editor/ToolbarExpanded.svelte b/src/lib/editor/ToolbarExpanded.svelte index d141be9..10620f8 100644 --- a/src/lib/editor/ToolbarExpanded.svelte +++ b/src/lib/editor/ToolbarExpanded.svelte @@ -124,10 +124,85 @@ return mixed ? '' : (lh ?? DEFAULT_LINE_HEIGHT); }); + // Word/LibreOffice-style palette: greyscale + standard colors + tints + shades. + const COLOR_PALETTE: string[][] = [ + ['#000000', '#1A1A1A', '#333333', '#4D4D4D', '#666666', '#808080', '#999999', '#B3B3B3', '#CCCCCC', '#FFFFFF'], + ['#C00000', '#FF0000', '#FFC000', '#FFFF00', '#92D050', '#00B050', '#00B0F0', '#0070C0', '#002060', '#7030A0'], + ['#F4B5B5', '#FFB3B3', '#FFE08A', '#FFFFB3', '#C6E0B4', '#A9D08E', '#9DC3E6', '#BDD7EE', '#8FAADC', '#B4A7D6'], + ['#800000', '#A50000', '#BF8F00', '#BFBF00', '#538135', '#375623', '#2E75B6', '#1F4E79', '#0B274D', '#4A2474'], + ]; + + // Uniform color of the selection. null = no color anywhere; '' = mixed. + let currentColor = $derived.by(() => { + if (tick < 0 || !editor) return null; + const { from, to, empty } = editor.state.selection; + if (empty) { + const marks = editor.state.storedMarks ?? editor.state.selection.$head.marks(); + return marks.find(m => m.type.name === 'textStyle')?.attrs.color ?? null; + } + let color: string | null | undefined; + let mixed = false; + editor.state.doc.nodesBetween(from, to, (node) => { + if (mixed || !node.isText) return; + const c = node.marks.find(m => m.type.name === 'textStyle')?.attrs.color ?? null; + if (color === undefined) color = c; + else if (color !== c) mixed = true; + }); + return mixed ? '' : (color ?? null); + }); + let fontOpen = $state(false); let sizeOpen = $state(false); let lineHeightOpen = $state(false); let alignOpen = $state(false); + let colorOpen = $state(false); + let lastColor = $state('#C00000'); + let moreColorsOpen = $state(false); + let pickerH = $state(0); + let pickerS = $state(1); + let pickerV = $state(0.75); + let stagedColor = $derived(hsvToHex(pickerH, pickerS, pickerV)); + + function clamp01(n: number): number { + return n < 0 ? 0 : n > 1 ? 1 : n; + } + + function hsvToHex(h: number, s: number, v: number): string { + const c = v * s; + const hh = ((h % 360) + 360) % 360 / 60; + const x = c * (1 - Math.abs((hh % 2) - 1)); + let r = 0, g = 0, b = 0; + if (hh < 1) { r = c; g = x; b = 0; } + else if (hh < 2) { r = x; g = c; b = 0; } + else if (hh < 3) { r = 0; g = c; b = x; } + else if (hh < 4) { r = 0; g = x; b = c; } + else if (hh < 5) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + const m = v - c; + const hex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0'); + return `#${hex(r)}${hex(g)}${hex(b)}`.toUpperCase(); + } + + function hexToHsv(hex: string): { h: number; s: number; v: number } | null { + const m = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) return null; + const r = parseInt(m[1].slice(0, 2), 16) / 255; + const g = parseInt(m[1].slice(2, 4), 16) / 255; + const b = parseInt(m[1].slice(4, 6), 16) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + if (d !== 0) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + const s = max === 0 ? 0 : d / max; + return { h, s, v: max }; + } let sizeInputFocused = $state(false); let sizeInputValue = $state(''); let savedFrom: number | null = null; @@ -145,6 +220,7 @@ sizeInputFocused = false; lineHeightOpen = false; alignOpen = false; + colorOpen = false; // Save selection before the picker button steals focus. savedFrom = editor.state.selection.from; savedTo = editor.state.selection.to; @@ -184,6 +260,7 @@ fontOpen = false; lineHeightOpen = false; alignOpen = false; + colorOpen = false; savedFrom = editor.state.selection.from; savedTo = editor.state.selection.to; (e.target as HTMLInputElement).select(); @@ -211,6 +288,7 @@ fontOpen = false; lineHeightOpen = false; alignOpen = false; + colorOpen = false; if (!sizeInputFocused) { savedFrom = editor.state.selection.from; savedTo = editor.state.selection.to; @@ -241,6 +319,7 @@ sizeOpen = false; sizeInputFocused = false; alignOpen = false; + colorOpen = false; lineHeightOpen = !lineHeightOpen; } @@ -264,6 +343,7 @@ sizeOpen = false; sizeInputFocused = false; lineHeightOpen = false; + colorOpen = false; alignOpen = !alignOpen; } @@ -280,6 +360,135 @@ window.addEventListener('mousedown', handler); return { destroy() { window.removeEventListener('mousedown', handler); } }; } + + function openColorPicker() { + if (!editor) return; + fontOpen = false; + sizeOpen = false; + sizeInputFocused = false; + lineHeightOpen = false; + alignOpen = false; + moreColorsOpen = false; + savedFrom = editor.state.selection.from; + savedTo = editor.state.selection.to; + colorOpen = !colorOpen; + } + + function applyColor(color: string) { + if (!editor) return; + colorOpen = false; + moreColorsOpen = false; + lastColor = color; + const from = savedFrom ?? editor.state.selection.from; + const to = savedTo ?? editor.state.selection.to; + savedFrom = null; + savedTo = null; + editor.chain().focus().setTextSelection({ from, to }).setColor(color).run(); + } + + function quickApplyColor() { + if (!editor) return; + // Save selection here too because the main button doesn't go through openColorPicker. + if (savedFrom === null) { + savedFrom = editor.state.selection.from; + savedTo = editor.state.selection.to; + } + applyColor(lastColor); + } + + function clearColor() { + if (!editor) return; + colorOpen = false; + moreColorsOpen = false; + const from = savedFrom ?? editor.state.selection.from; + const to = savedTo ?? editor.state.selection.to; + savedFrom = null; + savedTo = null; + editor.chain().focus().setTextSelection({ from, to }).unsetColor().run(); + } + + function openMoreColors() { + const seed = (currentColor && currentColor !== '') ? currentColor : lastColor; + const hsv = hexToHsv(seed) ?? { h: 0, s: 1, v: 0.75 }; + pickerH = hsv.h; + pickerS = hsv.s; + pickerV = hsv.v; + colorOpen = false; + moreColorsOpen = true; + } + + function confirmMoreColors() { + applyColor(stagedColor); + } + + function cancelMoreColors() { + moreColorsOpen = false; + } + + function onSvPointerDown(e: PointerEvent) { + // Keep the editor's text selection visible by stopping the browser from + // shifting focus / collapsing the selection on this click. + e.preventDefault(); + const el = e.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + const update = (ev: PointerEvent) => { + pickerS = clamp01((ev.clientX - rect.left) / rect.width); + pickerV = clamp01(1 - (ev.clientY - rect.top) / rect.height); + }; + el.setPointerCapture(e.pointerId); + update(e); + const move = (ev: PointerEvent) => update(ev); + const up = (ev: PointerEvent) => { + el.releasePointerCapture(ev.pointerId); + el.removeEventListener('pointermove', move); + el.removeEventListener('pointerup', up); + el.removeEventListener('pointercancel', up); + }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + el.addEventListener('pointercancel', up); + } + + function onHuePointerDown(e: PointerEvent) { + e.preventDefault(); + const el = e.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + const update = (ev: PointerEvent) => { + pickerH = clamp01((ev.clientY - rect.top) / rect.height) * 360; + }; + el.setPointerCapture(e.pointerId); + update(e); + const move = (ev: PointerEvent) => update(ev); + const up = (ev: PointerEvent) => { + el.releasePointerCapture(ev.pointerId); + el.removeEventListener('pointermove', move); + el.removeEventListener('pointerup', up); + el.removeEventListener('pointercancel', up); + }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + el.addEventListener('pointercancel', up); + } + + function onHexInput(e: Event) { + const value = (e.target as HTMLInputElement).value; + const hsv = hexToHsv(value); + if (!hsv) return; + pickerH = hsv.h; + pickerS = hsv.s; + pickerV = hsv.v; + } + + function colorPickerClickOutside(node: HTMLElement) { + function handler(e: MouseEvent) { + if (!node.contains(e.target as Node)) { + colorOpen = false; + moreColorsOpen = false; + } + } + window.addEventListener('mousedown', handler); + return { destroy() { window.removeEventListener('mousedown', handler); } }; + } @@ -341,12 +550,76 @@ - - - A - - - + + + + + A + + + + + + + + + + {#if colorOpen} + + Automatic + + {#each COLOR_PALETTE as row} + + {#each row as c} + applyColor(c)} + aria-label={c} + > + {/each} + + {/each} + + More colors… + + {/if} + {#if moreColorsOpen} + + + + + + + + + + + + + + + Abbrechen + OK + + + {/if} + @@ -859,4 +1132,303 @@ background: var(--color-primary); color: white; } + + .color-picker { + position: relative; + } + + .color-split { + display: inline-flex; + align-items: stretch; + height: 2rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + overflow: hidden; + transition: border-color 0.15s; + } + + .color-split:hover { + border-color: var(--color-primary); + } + + .color-main { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + min-width: 1.7rem; + height: 100%; + padding: 0 0.35rem; + border: none; + border-radius: 0; + background: transparent; + color: var(--color-text); + cursor: pointer; + } + + .color-main:hover { + background: var(--color-btn-hover); + } + + .color-bar { + display: block; + width: 14px; + height: 3px; + border-radius: 1px; + } + + .color-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + min-width: unset; + height: 100%; + padding: 0; + border: none; + border-left: 1px solid var(--color-border); + border-radius: 0; + background: transparent; + color: var(--color-text); + cursor: pointer; + } + + .color-chevron:hover { + background: var(--color-btn-hover); + } + + .color-dropdown { + position: absolute; + top: calc(100% + 3px); + left: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 200; + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .color-automatic { + display: block; + width: 100%; + padding: 0.35rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: calc(var(--radius) - 2px); + background: transparent; + color: var(--color-text); + font-size: 0.8rem; + font-family: var(--font-sans); + text-align: center; + cursor: pointer; + min-width: unset; + height: auto; + transition: background 0.1s; + } + + .color-automatic:hover { + background: var(--color-btn-hover); + } + + .color-automatic.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); + } + + .color-grid { + display: flex; + flex-direction: column; + gap: 2px; + } + + .color-row { + display: flex; + gap: 2px; + } + + .color-cell { + width: 18px; + height: 18px; + min-width: unset; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 2px; + cursor: pointer; + transition: transform 0.05s; + } + + .color-cell:hover { + transform: scale(1.15); + } + + .color-cell.active { + outline: 2px solid var(--color-primary); + outline-offset: 1px; + } + + .color-more-trigger { + display: block; + width: 100%; + padding: 0.35rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: calc(var(--radius) - 2px); + background: transparent; + color: var(--color-text); + font-size: 0.8rem; + font-family: var(--font-sans); + text-align: center; + cursor: pointer; + min-width: unset; + height: auto; + transition: background 0.1s; + } + + .color-more-trigger:hover { + background: var(--color-btn-hover); + } + + .color-window { + position: absolute; + top: calc(100% + 3px); + left: 0; + width: 210px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 200; + } + + .color-window-body { + display: flex; + gap: 8px; + } + + .sv-square { + position: relative; + width: 160px; + height: 160px; + cursor: crosshair; + border: 1px solid var(--color-border); + background: + linear-gradient(to top, #000, transparent), + linear-gradient(to right, #fff, hsl(var(--hue, 0), 100%, 50%)); + touch-action: none; + } + + .sv-thumb { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); + transform: translate(-50%, -50%); + pointer-events: none; + } + + .hue-slider { + position: relative; + width: 18px; + height: 160px; + cursor: pointer; + border: 1px solid var(--color-border); + background: linear-gradient( + to bottom, + #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100% + ); + touch-action: none; + } + + .hue-thumb { + position: absolute; + left: -3px; + width: 22px; + height: 6px; + border: 1px solid white; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6); + transform: translateY(-50%); + pointer-events: none; + } + + .color-window-readout { + display: flex; + align-items: center; + gap: 6px; + } + + .color-window-swatch { + display: block; + width: 28px; + height: 24px; + border: 1px solid var(--color-border); + border-radius: 3px; + flex-shrink: 0; + } + + .color-window-hex { + flex: 1; + min-width: 0; + height: 24px; + padding: 0 0.4rem; + border: 1px solid var(--color-border); + border-radius: 3px; + background: var(--color-surface); + color: var(--color-text); + font-size: 0.8rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + outline: none; + } + + .color-window-hex:focus { + border-color: var(--color-primary); + } + + .color-more-actions { + display: flex; + gap: 4px; + justify-content: flex-end; + } + + .color-confirm, + .color-cancel { + padding: 0.25rem 0.6rem; + border-radius: calc(var(--radius) - 2px); + font-size: 0.75rem; + font-family: var(--font-sans); + cursor: pointer; + min-width: unset; + height: auto; + transition: background 0.1s, border-color 0.15s; + } + + .color-cancel { + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text); + } + + .color-cancel:hover { + background: var(--color-btn-hover); + } + + .color-confirm { + border: 1px solid var(--color-primary); + background: var(--color-primary); + color: white; + } + + .color-confirm:hover { + filter: brightness(1.1); + } diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 03dbf3f..b10821e 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -6,6 +6,7 @@ import Italic from '@tiptap/extension-italic'; import Underline from '@tiptap/extension-underline'; import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style'; import { FontWeight } from './fontWeight'; +import { FontColor } from './fontColor'; import Heading from '@tiptap/extension-heading'; import BulletList from '@tiptap/extension-bullet-list'; import OrderedList from '@tiptap/extension-ordered-list'; @@ -28,6 +29,7 @@ export const extensions = [ FontFamily, FontSize, FontWeight, + FontColor, LineHeight, Heading.configure({ levels: [1, 2, 3] }), BulletList, diff --git a/src/lib/editor/fontColor.ts b/src/lib/editor/fontColor.ts new file mode 100644 index 0000000..7e1057c --- /dev/null +++ b/src/lib/editor/fontColor.ts @@ -0,0 +1,62 @@ +import { Extension } from '@tiptap/core'; +import '@tiptap/extension-text-style'; + +declare module '@tiptap/core' { + interface Commands { + fontColor: { + setColor: (color: string) => ReturnType; + unsetColor: () => ReturnType; + }; + } +} + +/** + * Adds `color` as an attribute on the TextStyle mark. Emits a `data-color` + * marker alongside the inline style so theme CSS (e.g. allBlack) can target + * only color-bearing spans without affecting other inline styles. + */ +export const FontColor = Extension.create({ + name: 'fontColor', + + addOptions() { + return { types: ['textStyle'] }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + color: { + default: null, + parseHTML: element => element.style.color || null, + renderHTML: attributes => { + if (!attributes.color) return {}; + return { + style: `color: ${attributes.color}`, + 'data-color': attributes.color, + }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setColor: + (color: string) => + ({ chain }) => + chain().setMark('textStyle', { color }).run(), + + unsetColor: + () => + ({ chain }) => + chain() + .setMark('textStyle', { color: null }) + .removeEmptyTextStyle() + .run(), + }; + }, +}); diff --git a/src/styles/editor.css b/src/styles/editor.css index cc648d6..02fe0f4 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -262,3 +262,9 @@ content: none; } } + +/* allBlack theme: force all explicitly-colored text to the page text color. + The color is still stored on the textStyle mark, so .odt exports remain faithful. */ +[data-theme="allBlack"] .tiptap [data-color] { + color: var(--color-page-text) !important; +}