feat: visual palette picker with accurate color previews (1.8.2)
This commit is contained in:
1 parent
c1b152b376
commit
55dab4bc1e
11 files changed
+347
-39
No files matched your search
@@ -0,0 +1,125 @@
|
||||
/** A compact palette picker. Palette references stay references when saved. */
|
||||
let sequence = 0;
|
||||
const node = (tag, className, text) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.setAttribute('aria-controls', `${id}-choices`);
|
||||
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
|
||||
const ink = node('span', 'color-sample-ink'); sample.append(ink);
|
||||
const description = node('span', 'color-description');
|
||||
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
|
||||
description.append(name, detail);
|
||||
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
|
||||
trigger.append(sample, description, arrow);
|
||||
|
||||
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
|
||||
const heading = node('p', 'color-heading', 'Website-Farben');
|
||||
const palette = node('div', 'color-palette');
|
||||
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
|
||||
const buttons = [];
|
||||
let current = value, editing = false, mergeKey = `color-${++sequence}`;
|
||||
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
|
||||
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
|
||||
const restoreFocus = () => document.getElementById(id)?.focus();
|
||||
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
|
||||
for (const color of colors) {
|
||||
const button = node('button', 'color-option'); button.type = 'button';
|
||||
button.dataset.color = `@${color.key}`;
|
||||
button.title = `${color.name} · ${color.value}`;
|
||||
button.setAttribute('aria-label', button.title);
|
||||
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
button.append(swatch, node('span', 'color-option-name', color.name));
|
||||
button.addEventListener('click', () => choose(`@${color.key}`));
|
||||
buttons.push(button); palette.append(button);
|
||||
}
|
||||
if (colors.length) choices.append(heading, palette);
|
||||
|
||||
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
|
||||
const custom = node('div', 'color-custom');
|
||||
const picker = node('input', 'color-native'); picker.type = 'color';
|
||||
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
|
||||
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
|
||||
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
|
||||
text.setAttribute('aria-label', `Farbwert für ${label}`);
|
||||
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
|
||||
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
|
||||
custom.append(picker, text, accept);
|
||||
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
|
||||
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
|
||||
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
|
||||
clear.addEventListener('click', () => choose(''));
|
||||
choices.append(customLabel, custom, error, clear);
|
||||
wrap.append(trigger, choices);
|
||||
|
||||
function sync() {
|
||||
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
|
||||
ink.style.backgroundColor = hex || 'transparent';
|
||||
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
|
||||
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
|
||||
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
|
||||
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
|
||||
text.value = hex || current || '';
|
||||
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
|
||||
clear.disabled = current === '';
|
||||
}
|
||||
const finish = () => {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
onComplete();
|
||||
};
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!choices.hidden) {
|
||||
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
} else {
|
||||
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
|
||||
picker.addEventListener('input', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
editing = true; sync(); onChange(current, mergeKey);
|
||||
});
|
||||
picker.addEventListener('change', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
sync(); onChange(current, editing ? mergeKey : undefined);
|
||||
if (editing) finish();
|
||||
restoreFocus();
|
||||
});
|
||||
const applyText = () => {
|
||||
let raw = text.value.trim();
|
||||
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
|
||||
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
|
||||
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
|
||||
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
|
||||
text.focus(); return;
|
||||
}
|
||||
choose(raw);
|
||||
};
|
||||
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
|
||||
text.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
|
||||
});
|
||||
accept.addEventListener('click', applyText);
|
||||
wrap.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || choices.hidden) return;
|
||||
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
});
|
||||
// Commit after leaving the native picker even on browsers that omit its change event.
|
||||
wrap.addEventListener('focusout', () => setTimeout(() => {
|
||||
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
|
||||
}, 0));
|
||||
sync();
|
||||
return wrap;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -204,31 +205,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const colorControl = (id, value, apply) => {
|
||||
const wrap = element('div', 'color-control');
|
||||
const picker = document.createElement('input');
|
||||
picker.type = 'color';
|
||||
picker.id = id;
|
||||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
||||
const text = document.createElement('input');
|
||||
text.type = 'text';
|
||||
text.className = 'color-text';
|
||||
text.value = value ?? '';
|
||||
text.placeholder = '#1a2b3c';
|
||||
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
|
||||
const palette = document.createElement('select');
|
||||
palette.className = 'color-palette';
|
||||
palette.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
palette.append(new Option('Palette …', ''));
|
||||
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
|
||||
palette.value = (value ?? '').startsWith('@') ? value : '';
|
||||
|
||||
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
|
||||
text.addEventListener('change', () => apply(text.value.trim()));
|
||||
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
|
||||
wrap.append(picker, text, palette);
|
||||
return wrap;
|
||||
};
|
||||
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
|
||||
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||||
onComplete: () => {
|
||||
const focusedId = document.activeElement?.id;
|
||||
onChange();
|
||||
if (focusedId) document.getElementById(focusedId)?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
@@ -539,7 +523,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
@@ -552,9 +536,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
let hoverInherited = current.value || '';
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (name === deviceName) break;
|
||||
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
|
||||
}
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||||
: property.type === 'number'
|
||||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||||
|
||||
@@ -131,15 +131,39 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
|
||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
||||
.color-control .color-text{font-size:12px;min-height:38px}
|
||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
||||
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-trigger:hover{background:#f5f7f3}
|
||||
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
|
||||
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
|
||||
.color-description{display:grid;gap:3px;flex:1;min-width:0}
|
||||
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
|
||||
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
|
||||
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
|
||||
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
|
||||
.color-choices[hidden]{display:none}
|
||||
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
|
||||
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
|
||||
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.color-option:hover{background:#eef3ea;border-color:#718575}
|
||||
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
|
||||
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
|
||||
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.color-custom{display:flex;align-items:center;gap:6px}
|
||||
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
|
||||
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
|
||||
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
|
||||
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
|
||||
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
|
||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||
.length-control.is-inherited input{color:var(--muted)}
|
||||
|
||||
Reference in new issue
Block a user