|
| 1 | +import { |
| 2 | + Decoration, |
| 3 | + EditorView, |
| 4 | + ViewPlugin, |
| 5 | + WidgetType, |
| 6 | +} from "@codemirror/view"; |
| 7 | +import pickColor from "dialogs/color"; |
| 8 | +import color from "utils/color"; |
| 9 | +import { colorRegex, HEX } from "utils/color/regex"; |
| 10 | + |
| 11 | +// WeakMap to carry state from widget DOM back into handler |
| 12 | +const colorState = new WeakMap(); |
| 13 | + |
| 14 | +const HEX_RE = new RegExp(HEX, "gi"); |
| 15 | + |
| 16 | +const RGBG = new RegExp(colorRegex.anyGlobal); |
| 17 | + |
| 18 | +const enumColorType = { hex: "hex", rgb: "rgb", hsl: "hsl", named: "named" }; |
| 19 | + |
| 20 | +class ColorWidget extends WidgetType { |
| 21 | + constructor({ color, colorRaw, ...state }) { |
| 22 | + super(); |
| 23 | + this.state = state; // from, to, colorType, alpha |
| 24 | + this.color = color; // hex for input value |
| 25 | + this.colorRaw = colorRaw; // original css color string |
| 26 | + } |
| 27 | + eq(other) { |
| 28 | + return ( |
| 29 | + other.state.colorType === this.state.colorType && |
| 30 | + other.color === this.color && |
| 31 | + other.state.from === this.state.from && |
| 32 | + other.state.to === this.state.to && |
| 33 | + (other.state.alpha || "") === (this.state.alpha || "") |
| 34 | + ); |
| 35 | + } |
| 36 | + toDOM() { |
| 37 | + const wrapper = document.createElement("span"); |
| 38 | + wrapper.className = "cm-color-chip"; |
| 39 | + wrapper.style.display = "inline-block"; |
| 40 | + wrapper.style.width = "0.9em"; |
| 41 | + wrapper.style.height = "0.9em"; |
| 42 | + wrapper.style.borderRadius = "2px"; |
| 43 | + wrapper.style.verticalAlign = "middle"; |
| 44 | + wrapper.style.margin = "0 2px"; |
| 45 | + wrapper.style.boxSizing = "border-box"; |
| 46 | + wrapper.style.border = "1px solid rgba(0,0,0,0.2)"; |
| 47 | + wrapper.style.backgroundColor = this.colorRaw; |
| 48 | + wrapper.dataset["color"] = this.color; |
| 49 | + wrapper.dataset["colorraw"] = this.colorRaw; |
| 50 | + wrapper.style.cursor = "pointer"; |
| 51 | + colorState.set(wrapper, this.state); |
| 52 | + return wrapper; |
| 53 | + } |
| 54 | + ignoreEvent() { |
| 55 | + return false; |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +function colorDecorations(view) { |
| 60 | + const deco = []; |
| 61 | + const ranges = view.visibleRanges; |
| 62 | + for (const { from, to } of ranges) { |
| 63 | + const text = view.state.doc.sliceString(from, to); |
| 64 | + // Any color using global matcher from utils (captures named/rgb/rgba/hsl/hsla/hex) |
| 65 | + RGBG.lastIndex = 0; |
| 66 | + for (let m; (m = RGBG.exec(text)); ) { |
| 67 | + const raw = m[2]; |
| 68 | + const start = from + m.index + m[1].length; |
| 69 | + const end = start + raw.length; |
| 70 | + const c = color(raw); |
| 71 | + const colorHex = c.hex.toString(false); |
| 72 | + deco.push( |
| 73 | + Decoration.widget({ |
| 74 | + widget: new ColorWidget({ |
| 75 | + from: start, |
| 76 | + to: end, |
| 77 | + color: colorHex, |
| 78 | + colorRaw: raw, |
| 79 | + colorType: enumColorType.named, |
| 80 | + }), |
| 81 | + side: -1, |
| 82 | + }).range(start), |
| 83 | + ); |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + return Decoration.set(deco, { sort: true }); |
| 88 | +} |
| 89 | + |
| 90 | +export const colorView = (showPicker = true) => |
| 91 | + ViewPlugin.fromClass( |
| 92 | + class ColorViewPlugin { |
| 93 | + constructor(view) { |
| 94 | + this.decorations = colorDecorations(view); |
| 95 | + } |
| 96 | + update(update) { |
| 97 | + if (update.docChanged || update.viewportChanged) { |
| 98 | + this.decorations = colorDecorations(update.view); |
| 99 | + } |
| 100 | + const readOnly = update.view.contentDOM.ariaReadOnly === "true"; |
| 101 | + const editable = update.view.contentDOM.contentEditable === "true"; |
| 102 | + const canBeEdited = readOnly === false && editable; |
| 103 | + this.changePicker(update.view, canBeEdited); |
| 104 | + } |
| 105 | + changePicker(view, canBeEdited) { |
| 106 | + const doms = view.contentDOM.querySelectorAll("input[type=color]"); |
| 107 | + doms.forEach((inp) => { |
| 108 | + if (!showPicker) { |
| 109 | + inp.setAttribute("disabled", ""); |
| 110 | + } else { |
| 111 | + canBeEdited |
| 112 | + ? inp.removeAttribute("disabled") |
| 113 | + : inp.setAttribute("disabled", ""); |
| 114 | + } |
| 115 | + }); |
| 116 | + } |
| 117 | + }, |
| 118 | + { |
| 119 | + decorations: (v) => v.decorations, |
| 120 | + eventHandlers: { |
| 121 | + click: async (e, view) => { |
| 122 | + const target = e.target; |
| 123 | + const chip = target?.closest?.(".cm-color-chip"); |
| 124 | + if (!chip) return false; |
| 125 | + // Respect read-only and setting toggle |
| 126 | + const readOnly = view.contentDOM.ariaReadOnly === "true"; |
| 127 | + const editable = view.contentDOM.contentEditable === "true"; |
| 128 | + const canBeEdited = !readOnly && editable; |
| 129 | + if (!canBeEdited) return true; |
| 130 | + const data = colorState.get(chip); |
| 131 | + if (!data) return false; |
| 132 | + try { |
| 133 | + const picked = await pickColor( |
| 134 | + chip.dataset.colorraw || chip.dataset.color, |
| 135 | + ); |
| 136 | + if (!picked) return true; |
| 137 | + view.dispatch({ |
| 138 | + changes: { from: data.from, to: data.to, insert: picked }, |
| 139 | + }); |
| 140 | + } catch { |
| 141 | + /* ignore */ |
| 142 | + } |
| 143 | + return true; |
| 144 | + }, |
| 145 | + }, |
| 146 | + }, |
| 147 | + ); |
| 148 | + |
| 149 | +export default colorView; |
0 commit comments