From f3f97cc3ea5e9a03f705c550cb182fb846281f74 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 24 Apr 2026 04:29:13 -0400 Subject: [PATCH] feat: Add hex swatch previews (#12150) * feat: Add hex previews, closes #860 * PR feedback --- shared/editor/components/Styles.ts | 19 +++ shared/editor/extensions/HexColorPreview.ts | 127 ++++++++++++++++++++ shared/editor/nodes/index.ts | 2 + shared/editor/styles/EditorStyleHelper.ts | 6 + 4 files changed, 154 insertions(+) create mode 100644 shared/editor/extensions/HexColorPreview.ts diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 88ac0b773d..1bc2999774 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -1733,6 +1733,25 @@ code { } } +.${EditorStyleHelper.hexColorSwatch} { + display: inline-block; + width: 0.75em; + height: 0.75em; + margin-left: 0.3em; + vertical-align: -0.05em; + border-radius: 50%; + background-clip: padding-box; + cursor: var(--pointer); +} + +.${ + props.theme.isDark + ? EditorStyleHelper.hexColorSwatchDark + : EditorStyleHelper.hexColorSwatchLight +} { + outline: 1px solid ${props.theme.codeBorder}; +} + mark { border-radius: 1px; padding: 2px 0; diff --git a/shared/editor/extensions/HexColorPreview.ts b/shared/editor/extensions/HexColorPreview.ts new file mode 100644 index 0000000000..df6b10fcd8 --- /dev/null +++ b/shared/editor/extensions/HexColorPreview.ts @@ -0,0 +1,127 @@ +import copy from "copy-to-clipboard"; +import type { EditorState } from "prosemirror-state"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { toast } from "sonner"; +import Extension from "../lib/Extension"; +import { EditorStyleHelper } from "../styles/EditorStyleHelper"; + +const HEX_COLOR_REGEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6})\b/g; + +type HexPluginState = { + decorations: DecorationSet; +}; + +const pluginKey = new PluginKey("hex_color_preview"); + +/** + * An editor extension that renders a small colored circle after any valid hex + * color code found inside an inline code mark. + */ +export default class HexColorPreview extends Extension { + get name() { + return "hex_color_preview"; + } + + get plugins() { + return [ + new Plugin({ + key: pluginKey, + state: { + init: (_, state) => ({ + decorations: this.buildDecorations(state), + }), + apply: (tr, pluginState, _oldState, newState) => { + if (!tr.docChanged) { + return pluginState; + } + return { + decorations: this.buildDecorations(newState), + }; + }, + }, + props: { + decorations: (state) => pluginKey.getState(state)?.decorations, + }, + }), + ]; + } + + private buildDecorations(state: EditorState): DecorationSet { + const codeMarkType = state.schema.marks.code_inline; + if (!codeMarkType) { + return DecorationSet.empty; + } + + const decorations: Decoration[] = []; + + state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) { + return; + } + + const codeMark = node.marks.find((mark) => mark.type === codeMarkType); + if (!codeMark) { + return; + } + + const text = node.text; + HEX_COLOR_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = HEX_COLOR_REGEX.exec(text)) !== null) { + const hex = match[0]; + const end = pos + match.index + hex.length; + + decorations.push( + Decoration.widget(end, () => this.createSwatch(hex), { + // Use side: -1 so the swatch renders before the fake-cursor widget + // from prosemirror-codemark, which uses side 0/-1 to represent the + // "inside"/"outside" cursor positions at mark boundaries. + side: -1, + key: `hex-${hex}`, + marks: [codeMark], + }) + ); + } + }); + + return DecorationSet.create(state.doc, decorations); + } + + private createSwatch(color: string): HTMLElement { + const swatch = document.createElement("span"); + swatch.className = EditorStyleHelper.hexColorSwatch; + swatch.setAttribute("aria-hidden", "true"); + swatch.style.backgroundColor = color; + + const luminance = this.getRelativeLuminance(color); + if (luminance > 0.85) { + swatch.classList.add(EditorStyleHelper.hexColorSwatchLight); + } else if (luminance < 0.1) { + swatch.classList.add(EditorStyleHelper.hexColorSwatchDark); + } + + swatch.addEventListener("mousedown", (event) => { + // Prevent the editor from moving the cursor into the code mark on click. + event.preventDefault(); + }); + swatch.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + copy(color); + toast.message(this.editor.props.dictionary.codeCopied); + }); + + return swatch; + } + + private getRelativeLuminance(hex: string): number { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const channel = (c: number) => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + } +} diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 2708c5b8f7..5192808349 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -1,5 +1,6 @@ import DateTime from "../extensions/DateTime"; import DeleteNearAtom from "../extensions/DeleteNearAtom"; +import HexColorPreview from "../extensions/HexColorPreview"; import History from "../extensions/History"; import MaxLength from "../extensions/MaxLength"; import TrailingNode from "../extensions/TrailingNode"; @@ -70,6 +71,7 @@ export const inlineExtensions: Nodes = [ DateTime, HardBreak, DeleteNearAtom, + HexColorPreview, ]; export const listExtensions: Nodes = [ diff --git a/shared/editor/styles/EditorStyleHelper.ts b/shared/editor/styles/EditorStyleHelper.ts index 9344e8e636..a77f34e033 100644 --- a/shared/editor/styles/EditorStyleHelper.ts +++ b/shared/editor/styles/EditorStyleHelper.ts @@ -28,6 +28,12 @@ export class EditorStyleHelper { static readonly codeWord = "code-word"; + static readonly hexColorSwatch = "hex-color-swatch"; + + static readonly hexColorSwatchLight = "hex-color-swatch-light"; + + static readonly hexColorSwatchDark = "hex-color-swatch-dark"; + /** Toggle button for collapsible code blocks */ static readonly codeBlockToggle = "code-block-toggle";