Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 195 additions & 24 deletions src/cm/colorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,33 @@ import {
} from "@codemirror/view";
import pickColor from "dialogs/color";
import color from "utils/color";
import { colorRegex, HEX } from "utils/color/regex";
import { colorRegex, isValidColor } from "utils/color/regex";

interface ColorWidgetState {
from: number;
to: number;
colorType: string;
alpha?: string;
colorRaw: string;
}

interface ColorWidgetParams extends ColorWidgetState {
color: string;
colorRaw: string;
}

interface DocRange {
from: number;
to: number;
}

// WeakMap to carry state from widget DOM back into handler
const colorState = new WeakMap<HTMLElement, ColorWidgetState>();

const HEX_RE = new RegExp(HEX, "gi");

const RGBG = new RegExp(colorRegex.anyGlobal);

const enumColorType = { hex: "hex", rgb: "rgb", hsl: "hsl", named: "named" };
const MAX_SCAN_CHARS = 20000;
const MAX_COLOR_CHIPS = 150;

const disallowedBoundaryBefore = new Set(["-", ".", "/", "#"]);
const disallowedBoundaryAfter = new Set(["-", ".", "/"]);
Expand Down Expand Up @@ -119,15 +124,16 @@ class ColorWidget extends WidgetType {

constructor({ color, colorRaw, ...state }: ColorWidgetParams) {
super();
this.state = state; // from, to, colorType, alpha
this.state = { ...state, colorRaw }; // from, to, colorType, alpha, original text
this.color = color; // hex for input value
this.colorRaw = colorRaw; // original css color string
this.colorRaw = this.state.colorRaw; // original css color string
}

eq(other: ColorWidget): boolean {
return (
other.state.colorType === this.state.colorType &&
other.color === this.color &&
other.colorRaw === this.colorRaw &&
other.state.from === this.state.from &&
other.state.to === this.state.to &&
(other.state.alpha || "") === (this.state.alpha || "")
Expand Down Expand Up @@ -158,15 +164,118 @@ class ColorWidget extends WidgetType {
}
}

function colorDecorations(view: EditorView): DecorationSet {
function normalizeRanges(ranges: readonly DocRange[]): DocRange[] {
if (!ranges.length) return [];
const sorted = [...ranges]
.map(({ from, to }) => {
const rangeFrom = Math.max(0, from);
return { from: rangeFrom, to: Math.max(rangeFrom, to) };
})
.sort((a, b) => a.from - b.from || a.to - b.to);
const merged: DocRange[] = [];

for (const range of sorted) {
const last = merged[merged.length - 1];
if (last && range.from <= last.to) {
last.to = Math.max(last.to, range.to);
continue;
}
merged.push(range);
}

return merged;
}

function mapRanges(
ranges: readonly DocRange[],
changes: ViewUpdate["changes"],
): DocRange[] {
return normalizeRanges(
ranges.map(({ from, to }) => ({
from: changes.mapPos(from, -1),
to: changes.mapPos(to, 1),
})),
);
}

function expandRangeToLines(doc: Text, from: number, to: number): DocRange {
if (doc.length === 0) return { from: 0, to: 0 };
const rangeFrom = Math.max(0, Math.min(doc.length, from));
const rangeTo = Math.max(rangeFrom, Math.min(doc.length, to));
const startLine = doc.lineAt(rangeFrom);
const endLine = doc.lineAt(rangeTo);
return { from: startLine.from, to: endLine.to };
}

function intersectsRange(from: number, to: number, range: DocRange): boolean {
if (from === to) return from >= range.from && from <= range.to;
return from < range.to && to > range.from;
}

function intersectsRanges(
from: number,
to: number,
ranges: readonly DocRange[],
): boolean {
return ranges.some((range) => intersectsRange(from, to, range));
}

function intersectRanges(
ranges: readonly DocRange[],
bounds: readonly DocRange[],
): DocRange[] {
const intersections: DocRange[] = [];
for (const range of ranges) {
for (const bound of bounds) {
const from = Math.max(range.from, bound.from);
const to = Math.min(range.to, bound.to);
if (from <= to) intersections.push({ from, to });
}
}
return normalizeRanges(intersections);
}

function subtractRanges(
ranges: readonly DocRange[],
coveredRanges: readonly DocRange[],
): DocRange[] {
const result: DocRange[] = [];
const covered = normalizeRanges(coveredRanges);

for (const range of normalizeRanges(ranges)) {
let from = range.from;
for (const cover of covered) {
if (cover.to <= from) continue;
if (cover.from >= range.to) break;
if (cover.from > from) {
result.push({ from, to: Math.min(cover.from, range.to) });
}
from = Math.max(from, cover.to);
if (from >= range.to) break;
}
if (from < range.to) result.push({ from, to: range.to });
}

return result;
}

function colorRanges(
view: EditorView,
ranges: readonly DocRange[],
): Range<Decoration>[] {
const deco: Range<Decoration>[] = [];
const ranges = view.visibleRanges;
const doc = view.state.doc;
let scannedChars = 0;
for (const { from, to } of ranges) {
const text = doc.sliceString(from, to);
if (deco.length >= MAX_COLOR_CHIPS || scannedChars >= MAX_SCAN_CHARS) break;
const scanTo = Math.min(to, from + (MAX_SCAN_CHARS - scannedChars));
if (scanTo <= from) continue;
const text = doc.sliceString(from, scanTo);
scannedChars += text.length;
// Any color using global matcher from utils (captures named/rgb/rgba/hsl/hsla/hex)
RGBG.lastIndex = 0;
for (let m: RegExpExecArray | null; (m = RGBG.exec(text)); ) {
if (deco.length >= MAX_COLOR_CHIPS) break;
const raw = m[2];
const start = from + m.index + m[1].length;
const end = start + raw.length;
Expand All @@ -188,40 +297,97 @@ function colorDecorations(view: EditorView): DecorationSet {
}
}

return Decoration.set(deco, true);
return deco;
}

class ColorViewPlugin {
decorations: DecorationSet;
raf = 0;
visibleRanges: DocRange[];
flushTimer = 0;
pendingView: EditorView | null = null;
pendingDirtyRanges: DocRange[] = [];

constructor(view: EditorView) {
this.decorations = colorDecorations(view);
this.decorations = Decoration.none;
this.visibleRanges = normalizeRanges(view.visibleRanges);
this.scheduleVisibleRanges(view);
}

update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.scheduleDecorations(update.view);
if (update.docChanged || update.viewportChanged || update.geometryChanged) {
this.scheduleDecorations(update);
}
const readOnly = update.view.contentDOM.ariaReadOnly === "true";
const editable = update.view.contentDOM.contentEditable === "true";
const canBeEdited = readOnly === false && editable;
this.changePicker(update.view, canBeEdited);
}

scheduleDecorations(view: EditorView): void {
scheduleVisibleRanges(view: EditorView): void {
this.pendingView = view;
if (this.raf) return;
// Color chips are decorative, so batch rapid viewport/doc changes into
// one animation frame instead of rebuilding on every intermediate update.
this.raf = requestAnimationFrame(() => {
this.raf = 0;
this.pendingDirtyRanges = normalizeRanges([
...this.pendingDirtyRanges,
...view.visibleRanges,
]);
this.scheduleFlush();
}

scheduleDecorations(update: ViewUpdate): void {
const view = update.view;
const doc = view.state.doc;
const visibleRanges = normalizeRanges(view.visibleRanges);
const mappedPreviousVisible = update.docChanged
? mapRanges(this.visibleRanges, update.changes)
: this.visibleRanges;
const dirtyRanges: DocRange[] = [];

if (update.docChanged) {
this.decorations = this.decorations.map(update.changes);
update.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => {
dirtyRanges.push(expandRangeToLines(doc, fromB, toB));
});
}

if (update.viewportChanged || update.geometryChanged) {
dirtyRanges.push(...subtractRanges(visibleRanges, mappedPreviousVisible));
}

this.pendingView = view;
this.pendingDirtyRanges = normalizeRanges([
...this.pendingDirtyRanges,
...dirtyRanges,
]);
this.visibleRanges = visibleRanges;

this.scheduleFlush();
}

scheduleFlush(): void {
if (this.flushTimer) return;
this.flushTimer = window.setTimeout(() => {
this.flushTimer = 0;
const pendingView = this.pendingView;
const dirtyRanges = this.pendingDirtyRanges;
this.pendingView = null;
this.pendingDirtyRanges = [];
if (!pendingView) return;
this.decorations = colorDecorations(pendingView);
this.flushDecorations(pendingView, dirtyRanges);
}, 80);
}

flushDecorations(view: EditorView, dirtyRanges: readonly DocRange[]): void {
const visibleRanges = normalizeRanges(view.visibleRanges);
const dirtyVisibleRanges = intersectRanges(dirtyRanges, visibleRanges);
const add = colorRanges(view, dirtyVisibleRanges);

this.decorations = this.decorations.update({
filter: (from, to) =>
intersectsRanges(from, to, visibleRanges) &&
!intersectsRanges(from, to, dirtyVisibleRanges),
add,
sort: true,
});
this.visibleRanges = visibleRanges;
}

changePicker(view: EditorView, canBeEdited: boolean): void {
Expand All @@ -237,11 +403,13 @@ class ColorViewPlugin {
}

destroy(): void {
if (this.raf) {
cancelAnimationFrame(this.raf);
this.raf = 0;
if (this.flushTimer) {
window.clearTimeout(this.flushTimer);
this.flushTimer = 0;
}
this.pendingView = null;
this.pendingDirtyRanges = [];
this.visibleRanges = [];
}
}

Expand All @@ -253,6 +421,7 @@ export const colorView = (showPicker = true) =>
const target = e.target as HTMLElement | null;
const chip = target?.closest?.(".cm-color-chip") as HTMLElement | null;
if (!chip) return false;
if (!showPicker) return true;
// Respect read-only and setting toggle
const readOnly = view.contentDOM.ariaReadOnly === "true";
const editable = view.contentDOM.contentEditable === "true";
Expand All @@ -264,6 +433,8 @@ export const colorView = (showPicker = true) =>
pickColor(chip.dataset.colorraw || chip.dataset.color || "")
.then((picked: string | null) => {
if (!picked) return;
const current = view.state.doc.sliceString(data.from, data.to);
if (current !== data.colorRaw || !isValidColor(current)) return;
view.dispatch({
changes: { from: data.from, to: data.to, insert: picked },
});
Expand Down
Loading