feat: add font color
This commit is contained in:
1 parent
a2e1a6ffe9
commit
3d8366c73f
5 files changed
+665
-8
No files matched your search
+17
-2
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user