frontend: tags

This commit is contained in:
2025-10-27 20:29:21 +01:00
parent 82aa8948cf
commit 7366d2e16b
6 changed files with 303 additions and 102 deletions
+11 -4
View File
@@ -105,11 +105,18 @@ const TagSection = ({
tags.map((tag) => {
const key = tag.id ?? tag.label;
const style = getTagColorStyle(tag.color);
const removable = Boolean(onRemove);
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
return (
<span key={key} className="tag-pill" style={style || undefined}>
{tag.label}{' '}
{onRemove ? (
<button type="button" onClick={() => onRemove(tag)}>
<span key={key} className={className} style={style || undefined}>
<span className="tag-chip__label">{tag.label}</span>
{removable ? (
<button
type="button"
className="tag-chip__remove"
onClick={() => onRemove(tag)}
aria-label={`Remove tag ${tag.label}`}
>
×
</button>
) : null}
+15 -26
View File
@@ -98,39 +98,28 @@
transition: transform 0.28s ease;
}
.skeuo-tag {
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding: 0.28rem 0.7rem;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg);
font-size: 1rem;
font-weight: 600;
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
text-align: left;
white-space: nowrap;
user-select: none;
}
.skeuo-item__tags .skeuo-tag {
font-size: 0.75rem;
padding: 0.18rem 0.55rem;
.tag-chip--draggable {
user-select: none;
pointer-events: auto;
cursor: grab;
transition: transform 0.16s ease, opacity 0.2s ease;
transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.18);
}
.skeuo-tag.is-drag-hidden {
.tag-chip--draggable:active {
cursor: grabbing;
}
.tag-chip--draggable.is-drag-hidden {
opacity: 0.4;
pointer-events: none;
}
.skeuo-tag span {
display: block;
pointer-events: none;
user-select: none;
.skeuo-item__tags .tag-chip {
font-size: 0.85rem;
padding: 0.18rem 0.55rem;
gap: 0.3rem;
}
@@ -186,8 +175,8 @@
height: 100%;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4;
.skeuo-item__tags .tag-chip--tear-pending {
opacity: 0.35;
}
body.skeuo-cursor-remove,
+8 -28
View File
@@ -10,7 +10,7 @@ import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
import { getReadableTextColor } from './utils/colors';
import { getTagColorStyle } from './utils/colors';
import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220;
@@ -598,23 +598,6 @@ const clampCardDimensions = (width, height) => {
const formatTransform = (x, y, rotation = 0, scale = 1) =>
`translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`;
const normalizeColor = (input) => {
if (!input) return null;
const value = String(input).trim();
if (!value) return null;
if (/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value)) {
return value.length === 4
? `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`
: value;
}
if (/^[0-9a-fA-F]{6}$/.test(value)) {
return `#${value}`;
}
return null;
};
const getContrastingTextColor = (hex) => getReadableTextColor(hex, { light: '#1f2125' });
const SkeuomorphicWorkspace = ({
documents = [],
searchResults = null,
@@ -1758,21 +1741,18 @@ const SkeuomorphicWorkspace = ({
) {
return null;
}
const colorValue = normalizeColor(tag.color);
const foreground = getContrastingTextColor(colorValue || '#1b1f24');
const colorStyle = getTagColorStyle(tag.color);
const pendingRemoval =
pendingRemovalTag &&
pendingRemovalTag.docId === doc.id &&
pendingRemovalTag.tagId === tag.id;
const tagClasses = ['skeuo-tag'];
if (pendingRemoval) tagClasses.push('is-tear-pending');
const tagStyle =
colorValue ? { backgroundColor: colorValue, color: foreground } : undefined;
const tagClasses = ['badge', 'tag-chip', 'tag-chip--draggable'];
if (pendingRemoval) tagClasses.push('tag-chip--tear-pending');
return (
<div
<span
key={key}
className={tagClasses.join(' ')}
style={tagStyle}
style={colorStyle || undefined}
title={tag.label || 'Tag'}
draggable
onPointerDown={(event) => handleDocTagPointerDown(event, doc, tag)}
@@ -1780,8 +1760,8 @@ const SkeuomorphicWorkspace = ({
onDrag={handleDocTagDrag}
onDragEnd={(event) => handleDocTagDragEnd(event)}
>
<span>{tag.label || 'Tag'}</span>
</div>
<span className="tag-chip__label">{tag.label || 'Tag'}</span>
</span>
);
})}
</div>
+35 -27
View File
@@ -1192,7 +1192,7 @@ button.danger:hover:not([disabled]) {
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
font-weight: 600;
line-height: 1;
cursor: pointer;
background: var(--surface-soft);
@@ -1687,6 +1687,7 @@ button.danger:hover:not([disabled]) {
gap: 0.25rem;
border-radius: 1rem;
padding: 0.2rem 0.4rem;
font-weight: 600;
}
.tag-chip--more {
@@ -1695,6 +1696,39 @@ button.danger:hover:not([disabled]) {
color: var(--muted);
}
.tag-chip--removable {
gap: 0.35rem;
}
.tag-chip__label {
line-height: 1;
}
.tag-chip__remove {
background: none;
border: none;
color: inherit;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.9em;
line-height: 1;
cursor: pointer;
opacity: 0.8;
}
.tag-chip__remove:hover,
.tag-chip__remove:focus-visible {
opacity: 1;
}
.tag-chip__remove:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
border-radius: 50%;
}
.empty-state {
border: 1px dashed var(--border);
border-radius: 0;
@@ -2093,32 +2127,6 @@ button.danger:hover:not([disabled]) {
line-height: 1.45;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.18rem;
background: var(--surface-subtle);
color: var(--fg);
padding: 0.2rem 0.45rem;
border-radius: 2px;
font-size: 0.78rem;
border: 1px solid var(--border);
}
.tag-pill button {
background: none;
border: none;
color: inherit;
padding: 0;
cursor: pointer;
font-size: 0.85rem;
opacity: 0.8;
}
.tag-pill button:hover {
opacity: 1;
}
input,
textarea,
select {
+29 -9
View File
@@ -1,5 +1,9 @@
import React, { useCallback, useMemo, useState } from 'react';
import { getTagColorStyle, HEX_COLOR_PATTERN } from '../utils/colors';
import {
getTagColorStyle,
HEX_COLOR_PATTERN,
generateRandomTagColor,
} from '../utils/colors';
function TagsPanel({
tags,
@@ -170,6 +174,14 @@ function TagsPanel({
disabled={creating}
aria-label="Tag color (optional)"
/>
<button
type="button"
className="secondary"
onClick={() => setCreateColor(generateRandomTagColor())}
disabled={creating}
>
Random color
</button>
{createColor && (
<button
type="button"
@@ -244,14 +256,22 @@ function TagsPanel({
className="tags-table__color-picker"
value={colorPickerValue}
onChange={(event) => setDraftColor(event.target.value)}
disabled={saving || deletingId === tag.id}
aria-label="Pick tag color"
/>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving || deletingId === tag.id}
aria-label="Pick tag color"
/>
<button
type="button"
className="secondary"
onClick={() => setDraftColor(generateRandomTagColor())}
disabled={saving || deletingId === tag.id}
>
Random color
</button>
{draftColor && (
<button
type="button"
className="secondary"
onClick={() => setDraftColor('')}
disabled={saving || deletingId === tag.id}
>
Clear
+205 -8
View File
@@ -2,6 +2,8 @@ const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/;
const clamp01 = (value) => Math.min(1, Math.max(0, value));
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
const gammaEncode = (channel) =>
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
@@ -77,6 +79,37 @@ const hslToHex = (h, s, l) => {
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
};
const rgbToHsl = ({ r, g, b }) => {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
let hue = 0;
if (delta !== 0) {
if (max === rn) {
hue = ((gn - bn) / delta) % 6;
} else if (max === gn) {
hue = (bn - rn) / delta + 2;
} else {
hue = (rn - gn) / delta + 4;
}
hue *= 60;
if (hue < 0) hue += 360;
}
const lightness = (max + min) / 2;
let saturation = 0;
if (delta !== 0) {
saturation = delta / (1 - Math.abs(2 * lightness - 1));
}
return { h: hue, s: clamp01(saturation), l: clamp01(lightness) };
};
export const hexToRgb = (input) => {
if (!input) return null;
const match = HEX_COLOR_PATTERN.exec(input.trim());
@@ -102,20 +135,184 @@ export const relativeLuminance = ({ r, g, b }) => {
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
export const getReadableTextColor = (hex, { light = '#1f1f1f', dark = '#ffffff' } = {}) => {
const rgb = hexToRgb(hex);
if (!rgb) return dark;
const luminance = relativeLuminance(rgb);
return luminance > 0.6 ? light : dark;
const contrastRatio = (lumA, lumB) => {
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
return (lighter + 0.05) / (darker + 0.05);
};
const parseCandidateColor = (candidate) => {
const rgb = hexToRgb(candidate);
if (!rgb) return null;
return {
hex: rgb.hex,
luminance: relativeLuminance(rgb),
};
};
const contrastForPair = (backgroundHex, textHex) => {
const background = hexToRgb(backgroundHex);
const text = hexToRgb(textHex);
if (!background || !text) {
return 0;
}
return contrastRatio(relativeLuminance(background), relativeLuminance(text));
};
export const getReadableTextColor = (
hex,
{ light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' } = {},
) => {
const background = hexToRgb(hex);
if (!background) return fallback;
const backgroundLuminance = relativeLuminance(background);
const backgroundHsl = rgbToHsl(background);
const hueShift = 180;
const textHue = (backgroundHsl.h + hueShift) % 360;
const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85);
const minLightness = 0.05;
const maxLightness = 0.95;
const sampleCount = 24;
const buildCandidate = (lightness) => {
const light = clampRange(lightness, minLightness, maxLightness);
const hexValue = hslToHex(textHue, targetSaturation, light);
return parseCandidateColor(hexValue);
};
const candidates = new Map();
for (let index = 0; index < sampleCount; index += 1) {
const t = index / (sampleCount - 1);
const candidateLightness = minLightness + t * (maxLightness - minLightness);
const candidate = buildCandidate(candidateLightness);
if (candidate) {
candidates.set(candidate.hex, candidate);
}
}
[light, dark].forEach((preset) => {
const parsed = parseCandidateColor(preset);
if (parsed) {
candidates.set(parsed.hex, parsed);
}
});
if (candidates.size === 0) {
return fallback;
}
let best = null;
let bestRatio = -Infinity;
candidates.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
const MIN_CONTRAST = 4.5;
if (bestRatio < MIN_CONTRAST) {
const extremeLight = buildCandidate(maxLightness);
const extremeDark = buildCandidate(minLightness);
const extremes = [extremeLight, extremeDark].filter(Boolean);
extremes.forEach((candidate) => {
const ratio = contrastRatio(backgroundLuminance, candidate.luminance);
if (ratio > bestRatio) {
bestRatio = ratio;
best = candidate;
}
});
}
return best?.hex || fallback;
};
export const getTagColorStyle = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return null;
const baseHex = rgb.hex;
const baseHsl = rgbToHsl(rgb);
const baseText = getReadableTextColor(baseHex);
const baseRatio = contrastForPair(baseHex, baseText);
const TARGET_RATIO = 8;
const MIN_LIGHTNESS = 0.12;
const MAX_LIGHTNESS = 0.88;
const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18];
const seen = new Map();
const registerCandidate = (lightness) => {
const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS);
const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped);
if (!seen.has(hexValue)) {
seen.set(hexValue, clamped);
}
};
registerCandidate(baseHsl.l);
adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta));
let bestBackground = baseHex;
let bestText = baseText;
let bestRatio = baseRatio;
if (bestRatio >= TARGET_RATIO) {
return {
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
}
let compliantBackground = null;
let compliantText = null;
let compliantDelta = Infinity;
let compliantRatio = -Infinity;
seen.forEach((lightness, candidateHex) => {
const textHex = getReadableTextColor(candidateHex);
const ratio = contrastForPair(candidateHex, textHex);
const delta = Math.abs(lightness - baseHsl.l);
if (ratio > bestRatio) {
bestBackground = candidateHex;
bestText = textHex;
bestRatio = ratio;
}
if (ratio >= TARGET_RATIO) {
const deltaEpsilon = 0.0025;
const ratioEpsilon = 0.01;
const isCloser = delta + deltaEpsilon < compliantDelta;
const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon;
const improvesRatio = ratio > compliantRatio + ratioEpsilon;
if (
compliantBackground === null
|| isCloser
|| (isSimilarDistance && improvesRatio)
) {
compliantBackground = candidateHex;
compliantText = textHex;
compliantDelta = delta;
compliantRatio = ratio;
}
}
});
if (compliantBackground) {
return {
backgroundColor: compliantBackground,
borderColor: compliantBackground,
color: compliantText,
};
}
return {
backgroundColor: rgb.hex,
borderColor: rgb.hex,
color: getReadableTextColor(rgb.hex),
backgroundColor: bestBackground,
borderColor: bestBackground,
color: bestText,
};
};