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 @@
- +
+
+ + +
+ {#if colorOpen} +
+ +
+ {#each COLOR_PALETTE as row} +
+ {#each row as c} + + {/each} +
+ {/each} +
+ +
+ {/if} + {#if moreColorsOpen} + + {/if} +