feat: Add hex swatch previews (#12150)

* feat: Add hex previews, closes #860

* PR feedback
This commit is contained in:
Tom Moor
2026-04-24 04:29:13 -04:00
committed by GitHub
parent 4c4649346b
commit f3f97cc3ea
4 changed files with 154 additions and 0 deletions
+19
View File
@@ -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;
+127
View File
@@ -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<HexPluginState>("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<HexPluginState>({
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);
}
}
+2
View File
@@ -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 = [
@@ -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";