feat(web): role colors with picker, Material 3 draw, effects and inline markdown
This commit is contained in:
1 parent
bef8898cd8
commit
3b062687cd
20 files changed
+688
-83
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { drawColors } from './colorDraw';
|
||||
import { isCustomColor } from './roles';
|
||||
|
||||
describe('drawColors', () => {
|
||||
it('每个候选都是合法的自定义颜色,渐变 2~5 个色标', () => {
|
||||
for (const seed of [0, 0.31, 0.77, 0.999]) {
|
||||
const drawn = drawColors(() => seed);
|
||||
expect(drawn).toHaveLength(6);
|
||||
for (const { color } of drawn) expect(isCustomColor(color)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('同一个种子给出同样的结果,不同种子给出不同的颜色', () => {
|
||||
expect(drawColors(() => 0.2)).toEqual(drawColors(() => 0.2));
|
||||
expect(drawColors(() => 0.2)[0]!.color).not.toBe(drawColors(() => 0.6)[0]!.color);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawColors 类型', () => {
|
||||
it('纯色模式只抽纯色,渐变模式只抽渐变', () => {
|
||||
expect(drawColors(() => 0.4, 6, 'solid').every(({ color }) => color.startsWith('#'))).toBe(true);
|
||||
expect(drawColors(() => 0.4, 6, 'gradient').every(({ color }) => color.startsWith('g:'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { hexFromArgb, TonalPalette } from '@material/material-color-utilities';
|
||||
import { gradientOf } from './roles';
|
||||
|
||||
export interface DrawnColor { label: string; color: string }
|
||||
|
||||
/**
|
||||
* 按 Material 3 的配色规则抽颜色:随机一个种子色相,取 HCT 色调板(TonalPalette)里的颜色。
|
||||
* M3 的配色是 主色(primary)、次色(secondary,色相相同、彩度低)、第三色(tertiary,色相 +60°),
|
||||
* 这里沿用这种关系,并把彩度提高一些、色调固定在 50~70 的中间段:标签很小,亮色和深色主题下都要看得清。
|
||||
*/
|
||||
export function drawColors(random: () => number = Math.random, count = 6, kind: 'solid' | 'gradient' = 'gradient'): DrawnColor[] {
|
||||
const tone = (hue: number, chroma: number, level: number) => hexFromArgb(TonalPalette.fromHueAndChroma(((hue % 360) + 360) % 360, chroma).tone(level));
|
||||
const solids: ((hue: number) => DrawnColor)[] = [
|
||||
(h) => ({ label: '主色', color: tone(h, 48, 50) }),
|
||||
(h) => ({ label: '主色 · 深', color: tone(h, 48, 38) }),
|
||||
(h) => ({ label: '主色 · 浅', color: tone(h, 48, 66) }),
|
||||
(h) => ({ label: '次色', color: tone(h, 24, 50) }),
|
||||
(h) => ({ label: '第三色', color: tone(h + 60, 48, 50) }),
|
||||
(h) => ({ label: '互补色', color: tone(h + 180, 48, 50) }),
|
||||
];
|
||||
const gradients: ((hue: number) => DrawnColor)[] = [
|
||||
(h) => ({ label: '主色 → 第三色', color: gradientOf([tone(h, 48, 50), tone(h + 60, 48, 60)]) }),
|
||||
(h) => ({ label: '主色 → 次色 → 第三色', color: gradientOf([tone(h, 48, 45), tone(h + 30, 36, 60), tone(h + 60, 48, 70)]) }),
|
||||
(h) => ({ label: '邻近色', color: gradientOf([tone(h, 56, 55), tone(h + 40, 56, 55)]) }),
|
||||
(h) => ({ label: '互补色', color: gradientOf([tone(h, 48, 55), tone(h + 180, 48, 55)]) }),
|
||||
(h) => ({ label: '明暗', color: gradientOf([tone(h, 56, 35), tone(h, 48, 70)]) }),
|
||||
(h) => ({ label: '多彩', color: gradientOf([0, 1, 2, 3, 4].map((step) => tone(h + step * 72, 56, 60))) }),
|
||||
];
|
||||
const recipes = kind === 'solid' ? solids : gradients;
|
||||
// 每次抽的候选共用一个种子色相,同一批颜色彼此协调
|
||||
const hue = random() * 360;
|
||||
return Array.from({ length: count }, (_, at) => recipes[at % recipes.length]!(hue + (at >= recipes.length ? 30 : 0)));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractColors, hexToHsv, hsvToHex, parseHex } from './colorMath';
|
||||
|
||||
describe('colorMath', () => {
|
||||
it('hex 和 hsv 互转', () => {
|
||||
for (const hex of ['#ff0000', '#00ff00', '#3b6ea8', '#ffffff', '#000000', '#7b4fb3']) expect(hsvToHex(hexToHsv(hex))).toBe(hex);
|
||||
});
|
||||
it('parseHex 接受三位、六位、带或不带 #', () => {
|
||||
expect(parseHex('#ABC')).toBe('#aabbcc'); expect(parseHex('ff8a3d')).toBe('#ff8a3d'); expect(parseHex('#12345')).toBeUndefined();
|
||||
});
|
||||
it('从 CSS 渐变里按顺序取颜色,忽略透明度和位置', () => {
|
||||
expect(extractColors('background: linear-gradient(90deg, #ff8a3d 0%, rgb(240, 54, 107) 50%, #ffffff80 100%);')).toEqual(['#ff8a3d', '#f0366b', '#ffffff']);
|
||||
expect(extractColors('没有颜色')).toEqual([]);
|
||||
expect(extractColors('#111 #222 #333 #444 #555 #666')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface Hsv { h: number; s: number; v: number }
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
const byte = (value: number) => Math.round(clamp(value, 0, 255)).toString(16).padStart(2, '0');
|
||||
|
||||
/** "#rgb" / "#rrggbb"(大小写均可)转成小写 "#rrggbb",其他返回 undefined */
|
||||
export function parseHex(text: string): string | undefined {
|
||||
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text.trim());
|
||||
if (!m) return undefined;
|
||||
const hex = m[1]!.toLowerCase();
|
||||
return `#${hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex}`;
|
||||
}
|
||||
|
||||
export function hexToHsv(hex: string): Hsv {
|
||||
const value = parseHex(hex) ?? '#000000';
|
||||
const r = parseInt(value.slice(1, 3), 16) / 255; const g = parseInt(value.slice(3, 5), 16) / 255; const b = parseInt(value.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b); const delta = max - Math.min(r, g, b);
|
||||
let h = 0;
|
||||
if (delta) {
|
||||
if (max === r) h = ((g - b) / delta) % 6;
|
||||
else if (max === g) h = (b - r) / delta + 2;
|
||||
else h = (r - g) / delta + 4;
|
||||
h = (h * 60 + 360) % 360;
|
||||
}
|
||||
return { h, s: max ? delta / max : 0, v: max };
|
||||
}
|
||||
|
||||
export function hsvToHex({ h, s, v }: Hsv): string {
|
||||
const f = (n: number) => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); };
|
||||
return `#${byte(f(5) * 255)}${byte(f(3) * 255)}${byte(f(1) * 255)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一段文字里取出颜色:兼容在线渐变生成器复制出来的 CSS(linear-gradient(90deg, #ff8a3d 0%, rgb(240,54,107) 100%) 之类)。
|
||||
* 按出现顺序最多取 max 个,透明度忽略。
|
||||
*/
|
||||
export function extractColors(text: string, max = 5): string[] {
|
||||
const found: string[] = [];
|
||||
const re = /#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3})\b|rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})/gi;
|
||||
for (let m = re.exec(text); m && found.length < max; m = re.exec(text)) {
|
||||
if (m[1]) found.push(parseHex(m[1].length === 8 ? m[1].slice(0, 6) : m[1])!);
|
||||
else found.push(`#${byte(Number(m[2]))}${byte(Number(m[3]))}${byte(Number(m[4]))}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { InlineMarkdown } from './inlineMarkdown';
|
||||
|
||||
const html = (text: string) => renderToStaticMarkup(<InlineMarkdown text={text} />);
|
||||
|
||||
describe('InlineMarkdown', () => {
|
||||
it('支持粗体、斜体、删除线、代码', () => {
|
||||
expect(html('**a** *b* ~~c~~ `d`')).toBe('<strong>a</strong> <em>b</em> <s>c</s> <code>d</code>');
|
||||
});
|
||||
it('嵌套样式', () => { expect(html('**a *b***')).toBe('<strong>a <em>b</em></strong>'); });
|
||||
it('只允许 http(s) 链接', () => {
|
||||
expect(html('[x](https://a.b/c)')).toContain('href="https://a.b/c"');
|
||||
expect(html('[x](javascript:alert(1))')).toBe('[x](javascript:alert(1))');
|
||||
});
|
||||
it('HTML 一律当文字转义', () => { expect(html('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>'); });
|
||||
it('没闭合的符号按原样显示', () => { expect(html('**a')).toBe('**a'); });
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 身份组前后缀用的行内 Markdown:`code`、**粗体**、~~删除线~~、*斜体* / _斜体_、[文字](http(s) 链接)。
|
||||
* 只生成 React 元素,不接受任何 HTML,链接只允许 http(s),所以别人写的前后缀不会注入脚本或样式。
|
||||
* 没有闭合的符号按普通文字显示。
|
||||
*/
|
||||
const PATTERNS: { re: RegExp; render: (match: RegExpExecArray, key: string) => ReactNode }[] = [
|
||||
{ re: /`([^`]+)`/y, render: (m, key) => <code key={key}>{m[1]}</code> },
|
||||
{ re: /\*\*(.+?)\*\*(?!\*)/y, render: (m, key) => <strong key={key}>{parse(m[1]!, key)}</strong> },
|
||||
{ re: /~~(.+?)~~/y, render: (m, key) => <s key={key}>{parse(m[1]!, key)}</s> },
|
||||
{ re: /\*(?!\s)(.+?)\*/y, render: (m, key) => <em key={key}>{parse(m[1]!, key)}</em> },
|
||||
{ re: /\b_(?!\s)(.+?)_\b/y, render: (m, key) => <em key={key}>{parse(m[1]!, key)}</em> },
|
||||
{ re: /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/y, render: (m, key) => <a key={key} href={m[2]} target="_blank" rel="noreferrer noopener">{parse(m[1]!, key)}</a> },
|
||||
];
|
||||
|
||||
function parse(source: string, prefix: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let plain = ''; let at = 0; let n = 0;
|
||||
const flush = () => { if (plain) { out.push(plain); plain = ''; } };
|
||||
while (at < source.length) {
|
||||
let hit = false;
|
||||
for (const { re, render } of PATTERNS) {
|
||||
re.lastIndex = at;
|
||||
const match = re.exec(source);
|
||||
if (!match) continue;
|
||||
flush(); out.push(render(match, `${prefix}.${n++}`)); at += match[0].length; hit = true;
|
||||
break;
|
||||
}
|
||||
if (!hit) { plain += source[at]; at += 1; }
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function InlineMarkdown({ text }: { text: string }) {
|
||||
return <>{parse(text, 'm')}</>;
|
||||
}
|
||||
+63
-5
@@ -1,6 +1,9 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
|
||||
export const ROLE_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple'] as const;
|
||||
/** 早期的预设色名(老数据里可能还有,渐变的色值在 tokens.css 的 --grad-*);新的颜色都是自定义的 */
|
||||
export const ROLE_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple', 'sunset', 'ocean', 'aurora', 'candy', 'gold', 'rainbow'] as const;
|
||||
const GRADIENT_COLORS = ['sunset', 'ocean', 'aurora', 'candy', 'gold', 'rainbow'] as const;
|
||||
export type RoleColor = (typeof ROLE_COLORS)[number];
|
||||
|
||||
/** 与后端 WorkspacePermission 一致 */
|
||||
@@ -9,7 +12,61 @@ export const PERMISSIONS = [
|
||||
{ id: 'structure.manage', label: '管理频道和分组', hint: '重命名频道、调整频道所属分组、新建和删除分组。新建频道涉及密钥,仍只有空间主' },
|
||||
] as const;
|
||||
|
||||
export interface Badge { key: string; text: string; color: RoleColor | 'owner' }
|
||||
/** 与后端 WorkspaceRole.OwnerId 一致:空间主所在的固定身份组 */
|
||||
export const OWNER_ROLE_ID = '00000000-0000-4000-8000-000000000000';
|
||||
/** 还没拿到身份组数据时(老数据、离线)空间主的默认标签 */
|
||||
const OWNER_FALLBACK = { suffix: 'Owner', color: 'gold' };
|
||||
|
||||
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||
const CUSTOM_GRADIENT = /^g:(#[0-9a-fA-F]{6})(,#[0-9a-fA-F]{6}){1,4}$/;
|
||||
|
||||
/** 颜色后面可以接 "|效果":呼吸、闪烁、跑马灯(只对渐变有意义)、色相循环 */
|
||||
export const EFFECTS = [
|
||||
{ id: 'breathe', label: '呼吸' }, { id: 'blink', label: '闪烁' }, { id: 'marquee', label: '跑马灯' }, { id: 'cycle', label: '色相循环' },
|
||||
] as const;
|
||||
export type RoleEffect = (typeof EFFECTS)[number]['id'];
|
||||
const EFFECT_SUFFIX = /\|(breathe|blink|marquee|cycle)$/;
|
||||
|
||||
export function splitColor(color: string): { base: string; effect: RoleEffect | undefined } {
|
||||
const m = EFFECT_SUFFIX.exec(color);
|
||||
return m ? { base: color.slice(0, m.index), effect: m[1] as RoleEffect } : { base: color, effect: undefined };
|
||||
}
|
||||
export const joinColor = (base: string, effect: RoleEffect | undefined): string => (effect ? `${base}|${effect}` : base);
|
||||
export const effectOf = (color: string): RoleEffect | undefined => splitColor(color).effect;
|
||||
|
||||
/** 预设名、自定义纯色 "#rrggbb"、自定义渐变 "g:#a,#b[,#c]"(可带 "|效果") 之一;其余一律当作灰色 */
|
||||
export function normalizeColor(color: string | null | undefined): string {
|
||||
if (!color) return 'gray';
|
||||
const { base } = splitColor(color);
|
||||
return (ROLE_COLORS as readonly string[]).includes(base) || HEX.test(base) || CUSTOM_GRADIENT.test(base) ? color : 'gray';
|
||||
}
|
||||
|
||||
export const isGradient = (color: string): boolean => { const { base } = splitColor(color); return (GRADIENT_COLORS as readonly string[]).includes(base) || CUSTOM_GRADIENT.test(base); };
|
||||
|
||||
/** 自定义颜色没有对应的 CSS 类,色值通过变量传给样式;预设返回 undefined。跑马灯的渐变首尾同色,循环滚动才没有接缝 */
|
||||
export function customColorStyle(color: string): CSSProperties | undefined {
|
||||
const { base, effect } = splitColor(color);
|
||||
if (HEX.test(base)) return { '--c': base } as CSSProperties;
|
||||
if (CUSTOM_GRADIENT.test(base)) {
|
||||
const stops = base.slice(2).split(',');
|
||||
return { '--g': `linear-gradient(90deg, ${(effect === 'marquee' ? [...stops, stops[0]] : stops).join(', ')})` } as CSSProperties;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 早期的预设色名对应的自定义颜色:编辑器只处理自定义颜色,打开老的身份组时先换算过来 */
|
||||
const LEGACY: Record<string, string> = {
|
||||
gray: '#8a8a85', blue: '#3b6ea8', green: '#1f7a4d', amber: '#b25e00', red: '#b3261e', purple: '#7b4fb3',
|
||||
sunset: 'g:#ff8a3d,#f0366b', ocean: 'g:#1ea7d8,#5b5be0', aurora: 'g:#19b97a,#2c8fe6,#9a5bf0', candy: 'g:#f06bb3,#8b6bf0',
|
||||
gold: 'g:#d99a1c,#f2c94c,#c27a0a', rainbow: 'g:#e5484d,#f5a524,#3fb950,#2c8fe6,#9a5bf0',
|
||||
};
|
||||
export const toCustomColor = (color: string): string => LEGACY[color] ?? color;
|
||||
|
||||
export const gradientOf = (stops: readonly string[]): string => `g:${stops.join(',')}`;
|
||||
export const stopsOf = (color: string): string[] => { const { base } = splitColor(color); return CUSTOM_GRADIENT.test(base) ? base.slice(2).split(',') : []; };
|
||||
export const isCustomColor = (color: string): boolean => { const { base } = splitColor(color); return HEX.test(base) || CUSTOM_GRADIENT.test(base); };
|
||||
|
||||
export interface Badge { key: string; text: string; color: string }
|
||||
|
||||
const hasText = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
||||
|
||||
@@ -17,10 +74,11 @@ const hasText = (value: string | null | undefined): value is string => Boolean(v
|
||||
export function badgesOf(roles: readonly RoleInfo[], username: string | undefined, creator: string | undefined): { prefix: Badge[]; suffix: Badge[] } {
|
||||
const prefix: Badge[] = []; const suffix: Badge[] = [];
|
||||
if (!username) return { prefix, suffix };
|
||||
if (username === creator) suffix.push({ key: 'owner', text: '空间主', color: 'owner' });
|
||||
const isOwner = username === creator;
|
||||
if (isOwner && !roles.some((role) => role.id === OWNER_ROLE_ID)) suffix.push({ key: 'owner', text: OWNER_FALLBACK.suffix, color: OWNER_FALLBACK.color });
|
||||
for (const role of roles) {
|
||||
if (!role.members.includes(username)) continue;
|
||||
const color = (ROLE_COLORS as readonly string[]).includes(role.color ?? '') ? (role.color as RoleColor) : 'gray';
|
||||
if (role.id === OWNER_ROLE_ID ? !isOwner : !role.members.includes(username)) continue;
|
||||
const color = normalizeColor(role.color);
|
||||
if (hasText(role.prefix)) prefix.push({ key: role.id + ':p', text: role.prefix, color });
|
||||
if (hasText(role.suffix)) suffix.push({ key: role.id + ':s', text: role.suffix, color });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user