fix: one colour picker everywhere, and its panel lands under its button

The note-separator, page-decoration and table-line colours were native
<input type=color> or a 20-swatch grid; they now open the app's own picker,
which grew a swatch-only variant (no icon) for a dialog field plus a
disabled state. Its panels move to the top layer: a transformed ancestor is
the containing block for position:fixed, and the floating table toolbar has
one — cell shading opened its palette in the bottom-right corner.
This commit is contained in:
Steffen Becker committed 2026-08-14 23:35:14 +02:00
1 parent bf564fbab4
commit 2e9c3b20ed
4 files changed
+113 -87

No files matched your search

+37 -6
View File
@@ -12,6 +12,7 @@
chevronTitle,
clearLabel,
open = $bindable(false),
disabled = false,
onApply,
onClear,
onOpen,
@@ -25,10 +26,12 @@
chevronTitle: string;
clearLabel?: string;
open?: boolean;
disabled?: boolean;
onApply: (color: string, range: { from: number; to: number }) => void;
onClear: (range: { from: number; to: number }) => void;
onOpen?: () => void;
icon: Snippet;
// A toolbar button's glyph. A dialog field passes none and is the swatch alone.
icon?: Snippet;
} = $props();
// Row 1 = greyscale, row 2 = standard hues. Rows 3–7 are generated per column
@@ -80,6 +83,10 @@
// The panels are `fixed` and placed from the button's rect, so a scrolling
// ancestor (the style manager's field column) can't clip them.
let split = $state<HTMLElement>();
// …and they live in the top layer, whose containing block is the viewport whatever
// sits above the button: a transform makes an ancestor the containing block for
// `fixed`, and the floating table toolbar has one (it hangs itself above the table).
const topLayer = (node: HTMLElement) => node.showPopover();
let panel = $state<HTMLElement>();
let pos = $state({ top: 0, left: 0 });
@@ -416,18 +423,20 @@
<div class="color-picker" use:colorPickerClickOutside>
<div class="color-split" bind:this={split}>
<button class="color-main" onclick={quickApplyColor} {title}>
{@render icon()}
<span class="color-bar" style="background: {lastColor}"></span>
<!-- A toolbar button re-applies its last colour and shows it; a dialog field is one
value, so its swatch reads that value and every click opens the palette. -->
<button class="color-main" class:swatch={!icon} onclick={icon ? quickApplyColor : openPicker} {title} {disabled}>
{#if icon}{@render icon()}{/if}
<span class="color-bar" style="background: {icon ? lastColor : (currentColor || defaultColor)}"></span>
</button>
<button class="color-chevron" onclick={openPicker} tabindex="-1" title={chevronTitle}>
<button class="color-chevron" onclick={openPicker} tabindex="-1" title={chevronTitle} {disabled}>
<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 open && view === 'palette'}
<div class="color-dropdown" bind:this={panel} style="top: {pos.top}px; left: {pos.left}px">
<div class="color-dropdown" popover="manual" use:topLayer bind:this={panel} style="top: {pos.top}px; left: {pos.left}px">
<div class="color-title">{title}</div>
<button
class="color-automatic"
@@ -473,6 +482,7 @@
{/if}
{#if open && view === 'custom'}
<div class="color-window" role="dialog" aria-label={t().color.chooseColor}
popover="manual" use:topLayer
bind:this={panel} style="top: {pos.top}px; left: {pos.left}px">
<div class="color-window-body">
<div
@@ -555,6 +565,18 @@
border-radius: 1px;
}
/* No glyph above it, so the bar is the button: a swatch, outlined so white still
reads against the surface. */
.color-main.swatch .color-bar {
width: 22px;
height: 14px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px var(--color-border);
}
.color-split:has(button:disabled) { opacity: 0.5; }
.color-split button:disabled { cursor: default; }
.color-chevron {
display: inline-flex;
align-items: center;
@@ -575,6 +597,15 @@
background: var(--color-btn-hover);
}
/* A popover carries the UA's own centring (`inset: 0; margin: auto`), which would
beat the top/left placed above. */
.color-dropdown,
.color-window {
inset: auto;
margin: 0;
overflow: visible;
}
.color-dropdown {
position: fixed;
background: var(--color-surface);
+12 -4
View File
@@ -3,6 +3,7 @@
DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteRestart, type NoteSettings,
} from '../storage/noteSettings';
import { noteSettings, setNoteSettings } from '../storage/notes.svelte';
import ColorPicker from './ColorPicker.svelte';
import { styleSheet } from '../styles/sheet.svelte';
import { formatOrdinal } from '../utils/orderedListTypes';
import { t } from '../i18n/i18n.svelte';
@@ -152,10 +153,18 @@
{#each ALIGNS as a}<option value={a}>{t().align[a]}</option>{/each}
</select>
</label>
<label class="row">
<div class="row">
<span>{t().notesDialog.sepColor}</span>
<input type="color" value={sep.color} onchange={(e) => patchSep({ color: e.currentTarget.value })} />
</label>
<ColorPicker
editor={null}
currentColor={sep.color}
defaultColor={sep.color}
title={t().notesDialog.sepColor}
chevronTitle={t().notesDialog.sepColor}
onApply={(c) => patchSep({ color: c })}
onClear={() => patchSep({ color: DEFAULT_NOTE_SETTINGS.separator.color })}
/>
</div>
</fieldset>
{/if}
@@ -224,7 +233,6 @@
padding: 0 6px;
font: inherit;
}
input[type='color'] { width: 44px; padding: 2px; }
.affix { width: 68px; }
.actions { display: flex; align-items: center; gap: 8px; }
+53 -25
View File
@@ -4,6 +4,7 @@
type PageBorder, type PageDecor, type Watermark,
} from '../storage/pageDecor';
import { t } from '../i18n/i18n.svelte';
import ColorPicker from './ColorPicker.svelte';
// LibreOffice's Format ▸ Page Style ▸ Area and Borders plus its Format ▸ Watermark,
// in one panel — all three decorate the page and all three ride the page layout.
@@ -13,6 +14,8 @@
} = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
// The colour a page background starts at, LibreOffice's own first "recent" yellow.
const DEFAULT_BACKGROUND = '#FFFFCC';
$effect(() => {
const el = dialogEl;
@@ -43,32 +46,49 @@
<fieldset>
<legend>{t().pageDecor.background}</legend>
<label class="row">
<input
type="checkbox"
checked={!!decor.background}
onchange={(e) => (decor = { ...decor, background: e.currentTarget.checked ? (decor.background ?? '#ffffcc') : null })}
<div class="row">
<label class="grow">
<input
type="checkbox"
checked={!!decor.background}
onchange={(e) => (decor = { ...decor, background: e.currentTarget.checked ? (decor.background ?? DEFAULT_BACKGROUND) : null })}
/>
<span>{t().pageDecor.useBackground}</span>
</label>
<ColorPicker
editor={null}
currentColor={decor.background ?? null}
defaultColor={decor.background ?? DEFAULT_BACKGROUND}
title={t().pageDecor.background}
chevronTitle={t().pageDecor.background}
onApply={(c) => (decor = { ...decor, background: c })}
onClear={() => (decor = { ...decor, background: null })}
/>
<span>{t().pageDecor.useBackground}</span>
<input
type="color"
value={decor.background ?? '#ffffcc'}
onchange={(e) => (decor = { ...decor, background: e.currentTarget.value })}
/>
</label>
</div>
</fieldset>
<fieldset>
<legend>{t().pageDecor.border}</legend>
<label class="row">
<input
type="checkbox"
checked={!!decor.border}
onchange={(e) => (decor = { ...decor, border: e.currentTarget.checked ? (decor.border ?? DEFAULT_PAGE_BORDER) : null })}
<div class="row">
<label class="grow">
<input
type="checkbox"
checked={!!decor.border}
onchange={(e) => (decor = { ...decor, border: e.currentTarget.checked ? (decor.border ?? DEFAULT_PAGE_BORDER) : null })}
/>
<span>{t().pageDecor.useBorder}</span>
</label>
<ColorPicker
editor={null}
currentColor={decor.border?.color ?? DEFAULT_PAGE_BORDER.color}
defaultColor={decor.border?.color ?? DEFAULT_PAGE_BORDER.color}
title={t().pageDecor.border}
chevronTitle={t().pageDecor.border}
disabled={!decor.border}
onApply={(c) => patchBorder({ color: c })}
onClear={() => patchBorder({ color: DEFAULT_PAGE_BORDER.color })}
/>
<span>{t().pageDecor.useBorder}</span>
<input type="color" value={decor.border?.color ?? DEFAULT_PAGE_BORDER.color} onchange={(e) => patchBorder({ color: e.currentTarget.value })} />
</label>
</div>
<label class="row">
<span class="grow">{t().pageDecor.borderWidth}</span>
<input type="number" min="0.25" max="20" step="0.25" disabled={!decor.border}
@@ -95,11 +115,19 @@
}}
/>
</label>
<label class="row">
<div class="row">
<span class="grow">{t().pageDecor.watermarkColor}</span>
<input type="color" value={decor.watermark?.color ?? DEFAULT_WATERMARK.color} disabled={!decor.watermark}
onchange={(e) => patchWatermark({ color: e.currentTarget.value })} />
</label>
<ColorPicker
editor={null}
currentColor={decor.watermark?.color ?? DEFAULT_WATERMARK.color}
defaultColor={decor.watermark?.color ?? DEFAULT_WATERMARK.color}
title={t().pageDecor.watermarkColor}
chevronTitle={t().pageDecor.watermarkColor}
disabled={!decor.watermark}
onApply={(c) => patchWatermark({ color: c })}
onClear={() => patchWatermark({ color: DEFAULT_WATERMARK.color })}
/>
</div>
<label class="row">
<span class="grow">{t().pageDecor.watermarkAngle}</span>
<input type="number" min="-180" max="180" step="5" disabled={!decor.watermark}
@@ -153,6 +181,7 @@
.row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.row .grow { flex: 1; color: var(--color-text-muted); }
.row span { color: var(--color-text-muted); }
.row label.grow { display: flex; align-items: center; gap: 8px; }
input[type='text'], input[type='number'] {
height: 26px;
@@ -164,7 +193,6 @@
padding: 0 6px;
font: inherit;
}
input[type='color'] { width: 44px; height: 26px; padding: 2px; border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); }
input:disabled { opacity: 0.5; }
.actions { display: flex; align-items: center; gap: 8px; }
+11 -52
View File
@@ -2,6 +2,7 @@
import type { Editor } from '@tiptap/core';
import { activeBorderPresets, type BorderPreset } from '../editor/extensions/tableCellBorders';
import { t } from '../i18n/i18n.svelte';
import ColorPicker from './ColorPicker.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -11,12 +12,6 @@
let color = $state('#000000');
const WIDTHS = [0.5, 1, 1.5, 2.25, 3];
// Greyscale + standard hues (the first two ColorPicker palette rows).
const COLORS = [
['#000000', '#1A1A1A', '#333333', '#4D4D4D', '#666666', '#808080', '#999999', '#B3B3B3', '#CCCCCC', '#FFFFFF'],
['#C00000', '#FF0000', '#FFC000', '#FFFF00', '#92D050', '#00B050', '#00B0F0', '#0070C0', '#002060', '#7030A0'],
];
// Preset icons: 6 segments (outer edges + the two inner grid lines); "on" segments
// draw solid, the rest as faint guides.
type Seg = 'top' | 'right' | 'bottom' | 'left' | 'h' | 'v';
@@ -136,22 +131,16 @@
</div>
<div class="bp-title">{t().borders.lineColor}</div>
<div class="bp-colors">
{#each COLORS as row}
<div class="bp-color-row">
{#each row as c}
<button
class="bp-color"
class:active={color === c}
style="background: {c}"
title={c}
aria-label={c}
onclick={() => (color = c)}
></button>
{/each}
</div>
{/each}
</div>
<ColorPicker
editor={null}
currentColor={color}
defaultColor={color}
title={t().borders.lineColor}
chevronTitle={t().borders.lineColor}
clearLabel={t().color.automatic}
onApply={(c) => (color = c)}
onClear={() => (color = '#000000')}
/>
</div>
{/if}
</div>
@@ -279,34 +268,4 @@
background: currentColor;
}
.bp-colors {
display: flex;
flex-direction: column;
gap: 2px;
}
.bp-color-row {
display: flex;
gap: 2px;
}
.bp-color {
width: 18px;
height: 18px;
min-width: unset;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 2px;
cursor: pointer;
transition: transform 0.05s;
}
.bp-color:hover {
transform: scale(1.15);
}
.bp-color.active {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
</style>