feat: add font color

This commit is contained in:
Steffen Becker committed 2026-05-29 10:41:12 +02:00
1 parent a2e1a6ffe9
commit 3d8366c73f
5 files changed
+665 -8

No files matched your search

+17 -2
View File
@@ -138,7 +138,12 @@
class:selected={themeMode === m}
onclick={() => selectTheme(m)}
role="menuitem"
>{label}</button>
>
<span>{label}</span>
{#if m === 'allBlack'}
<span class="theme-option-hint">font colors forced white</span>
{/if}
</button>
{/each}
</div>
{/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;
+578 -6
View File
@@ -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<string | null>(() => {
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<string>('#C00000');
let moreColorsOpen = $state(false);
let pickerH = $state<number>(0);
let pickerS = $state<number>(1);
let pickerV = $state<number>(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); } };
}
</script>
<div class="toolbar-expanded">
@@ -341,12 +550,76 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button disabled title="Font color (coming soon)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="3" y="11" font-size="10" font-weight="bold" font-family="sans-serif" fill="currentColor">A</text>
<rect x="2" y="13" width="12" height="2" rx="0.5" fill="currentColor"/>
</svg>
</button>
<div class="color-picker" use:colorPickerClickOutside>
<div class="color-split">
<button class="color-main" onclick={quickApplyColor} title="Font color">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="3" y="11" font-size="10" font-weight="bold" font-family="sans-serif" fill="currentColor">A</text>
</svg>
<span class="color-bar" style="background: {lastColor}"></span>
</button>
<button class="color-chevron" onclick={openColorPicker} tabindex="-1" title="Choose font color">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{#if colorOpen}
<div class="color-dropdown">
<button
class="color-automatic"
class:active={currentColor === null}
onclick={clearColor}
>Automatic</button>
<div class="color-grid">
{#each COLOR_PALETTE as row}
<div class="color-row">
{#each row as c}
<button
class="color-cell"
class:active={currentColor === c}
style="background: {c}"
title={c}
onclick={() => applyColor(c)}
aria-label={c}
></button>
{/each}
</div>
{/each}
</div>
<button class="color-more-trigger" onclick={openMoreColors}>More colors…</button>
</div>
{/if}
{#if moreColorsOpen}
<div class="color-window" role="dialog" aria-label="Choose color">
<div class="color-window-body">
<div
class="sv-square"
style="--hue: {pickerH}"
onpointerdown={onSvPointerDown}
role="presentation"
>
<div class="sv-thumb" style="left: {pickerS * 100}%; top: {(1 - pickerV) * 100}%"></div>
</div>
<div
class="hue-slider"
onpointerdown={onHuePointerDown}
role="presentation"
>
<div class="hue-thumb" style="top: {(pickerH / 360) * 100}%"></div>
</div>
</div>
<div class="color-window-readout">
<span class="color-window-swatch" style="background: {stagedColor}"></span>
<input class="color-window-hex" type="text" value={stagedColor} oninput={onHexInput} maxlength="7" />
</div>
<div class="color-more-actions">
<button class="color-cancel" onclick={cancelMoreColors}>Abbrechen</button>
<button class="color-confirm" onclick={confirmMoreColors}>OK</button>
</div>
</div>
{/if}
</div>
<button disabled title="Highlight color (coming soon)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/>
@@ -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);
}
</style>
+2
View File
@@ -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,
+62
View File
@@ -0,0 +1,62 @@
import { Extension } from '@tiptap/core';
import '@tiptap/extension-text-style';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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(),
};
},
});
+6
View File
@@ -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;
}