stylus/js/color/color-view.js

783 lines
23 KiB
JavaScript

/* global CodeMirror */
/* global colorConverter */
'use strict';
(() => {
//region Constants
const COLORVIEW_CLASS = 'colorview';
const COLORVIEW_SWATCH_CLASS = COLORVIEW_CLASS + '-swatch';
const COLORVIEW_SWATCH_CSS = `--${COLORVIEW_SWATCH_CLASS}:`;
const CLOSE_POPUP_EVENT = 'close-colorpicker-popup';
const RXS_NUM = /\s*([+-]?(?:\d+\.?\d*|\d*\.\d+))(?:e[+-]?\d+)?/.source;
const RX_COLOR = {
hex: /#(?:[a-f\d]{3}(?:[a-f\d](?:[a-f\d]{2}){0,2})?)\b/iy,
rgb: new RegExp([
// num, num, num [ , num_or_pct]?
// pct, pct, pct [ , num_or_pct]?
`^((${RXS_NUM}\\s*(,|$)){3}|(${RXS_NUM}%\\s*(,|$)){3})(${RXS_NUM}%?)?\\s*$`,
// num num num [ / num_or_pct]?
// pct pct pct [ / num_or_pct]?
`^((${RXS_NUM}\\s*(\\s|$)){3}|(${RXS_NUM}%\\s*(\\s|$)){3})(/${RXS_NUM}%?)?\\s*$`,
].join('|'), 'iy'),
hsl: new RegExp([
// num_or_angle, pct, pct [ , num_or_pct]?
`^(${RXS_NUM}(|deg|g?rad|turn)\\s*),(${RXS_NUM}%\\s*(,|$)){2}(${RXS_NUM}%?)?\\s*$`,
// num_or_angle pct pct [ / num_or_pct]?
`^(${RXS_NUM}(|deg|g?rad|turn)\\s*)\\s(${RXS_NUM}%\\s*(\\s|$)){2}(/${RXS_NUM}%?)?\\s*$`,
].join('|'), 'iy'),
unsupported: new RegExp([
!CSS.supports('color', '#abcd') && /#(.{4}){1,2}$/,
!CSS.supports('color', 'rgb(1e2,0,0)') && /\de/,
!CSS.supports('color', 'rgb(1.5,0,0)') && /^rgba?\((([^,]+,){0,2}[^,]*\.|(\s*\S+\s+){0,2}\S*\.)/,
!CSS.supports('color', 'rgb(1,2,3,.5)') && /[^a]\(([^,]+,){3}/,
!CSS.supports('color', 'rgb(1,2,3,50%)') && /\((([^,]+,){3}|(\s*\S+[\s/]+){3}).*?%/,
!CSS.supports('color', 'rgb(1 2 3 / 1)') && /^[^,]+$/,
!CSS.supports('color', 'hsl(1turn, 2%, 3%)') && /deg|g?rad|turn/,
].filter(Boolean).map(rx => rx.source).join('|') || '^$', 'i'),
};
if (RX_COLOR.unsupported.source === '^$') {
RX_COLOR.unsupported = null;
}
const RX_DETECT = new RegExp('(^|[\\s(){}[\\]:,/"=])' +
'(' +
RX_COLOR.hex.source + '|' +
'(?:rgb|hsl)a?(?=\\()|(?:' + [...colorConverter.NAMED_COLORS.keys()].join('|') + ')' +
'(?=[\\s;(){}[\\]/"!]|$)' +
')', 'gi');
const RX_DETECT_FUNC = /(rgb|hsl)a?\(/iy;
const RX_COMMENT = /\/\*([^*]|\*(?!\/))*(\*\/|$)/g;
const SPACE1K = ' '.repeat(1000);
// milliseconds to work on invisible colors per one run
const TIME_BUDGET = 50;
// on initial paint the view doesn't have a size yet
// so we process the maximum number of lines that can fit in the window
let maxRenderChunkSize = Math.ceil(window.innerHeight / 14);
//endregion
//region CodeMirror Events
const CM_EVENTS = {
changes(cm, info) {
colorizeChanges(cm.state.colorpicker, info);
},
update(cm) {
const textHeight = cm.display.cachedTextHeight;
const height = cm.display.lastWrapHeight;
if (!height || !textHeight) return;
maxRenderChunkSize = Math.max(20, Math.ceil(height / textHeight));
const state = cm.state.colorpicker;
if (state.colorizeOnUpdate) {
state.colorizeOnUpdate = false;
colorizeAll(state);
}
cm.off('update', CM_EVENTS.update);
},
mousedown(cm, event) {
const state = cm.state.colorpicker;
const swatch = hitTest(event);
dispatchEvent(new CustomEvent(CLOSE_POPUP_EVENT, {
detail: swatch && state.popup,
}));
if (swatch) {
event.preventDefault();
openPopupForSwatch(state, swatch);
}
},
};
//endregion
//region ColorSwatch
const cache = new Set();
class ColorSwatch {
constructor(cm, options = {}) {
this.cm = cm;
this.options = options;
this.markersToRemove = [];
this.markersToRepaint = [];
this.popup = cm.colorpicker && cm.colorpicker();
if (!this.popup) {
delete CM_EVENTS.mousedown;
document.head.appendChild(document.createElement('style')).textContent = `
.colorview-swatch::before {
cursor: auto;
}
`;
}
this.colorize();
this.registerEvents();
}
colorize() {
colorizeAll(this);
}
openPopup(color) {
if (this.popup) openPopupForCursor(this, color);
}
registerEvents() {
for (const name in CM_EVENTS) {
this.cm.on(name, CM_EVENTS[name]);
}
}
unregisterEvents() {
for (const name in CM_EVENTS) {
this.cm.off(name, CM_EVENTS[name]);
}
}
destroy() {
this.unregisterEvents();
const {cm} = this;
const {curOp} = cm;
if (!curOp) cm.startOperation();
cm.getAllMarks().forEach(m => m.className === COLORVIEW_CLASS && m.clear());
if (!curOp) cm.endOperation();
cm.state.colorpicker = null;
}
}
//endregion
//region CodeMirror registration
CodeMirror.defineOption('colorpicker', false, (cm, value, oldValue) => {
if (oldValue && oldValue !== CodeMirror.Init && cm.state.colorpicker) {
cm.state.colorpicker.destroy();
}
if (value) {
cm.state.colorpicker = new ColorSwatch(cm, value);
}
});
CodeMirror.prototype.getStyleAtPos = getStyleAtPos;
return;
//endregion
//region Colorizing
function colorizeAll(state) {
const {cm} = state;
const {viewFrom, viewTo} = cm.display;
if (!viewTo) {
state.colorizeOnUpdate = true;
return;
}
const {curOp} = cm;
if (!curOp) cm.startOperation();
state.line = viewFrom;
state.inComment = null;
state.now = performance.now();
state.stopAt = state.stopped = null;
cm.doc.iter(viewFrom, viewTo, lineHandle => colorizeLine(state, lineHandle));
updateMarkers(state);
if (!curOp) cm.endOperation();
if (viewFrom > 0 || viewTo < cm.doc.size) {
clearTimeout(state.colorizeTimer);
state.line = 0;
state.colorizeTimer = setTimeout(colorizeInvisible, 100, state, viewFrom, viewTo);
}
}
function colorizeInvisible(state, viewFrom, viewTo) {
const {cm} = state;
const {curOp} = cm;
if (!curOp) cm.startOperation();
state.now = performance.now();
state.stopAt = state.now + TIME_BUDGET;
state.stopped = null;
// before the visible range
cm.eachLine(state.line, viewFrom, lineHandle => colorizeLine(state, lineHandle));
// after the visible range
if (!state.stopped && viewTo < cm.doc.size) {
state.line = Math.max(viewTo, state.line);
cm.eachLine(state.line, cm.doc.size, lineHandle => colorizeLine(state, lineHandle));
}
updateMarkers(state);
if (!curOp) cm.endOperation();
if (state.stopped) {
state.colorizeTimer = setTimeout(colorizeInvisible, 0, state, viewFrom, viewTo);
}
}
function colorizeChanges(state, changes) {
if (changes.length === 1 && changes[0].origin === 'setValue') {
colorizeAll(state);
return;
}
const queue = [];
const postponed = [];
const viewFrom = state.cm.display.viewFrom || 0;
const viewTo = state.cm.display.viewTo || viewFrom + maxRenderChunkSize;
for (let change of changes) {
const {from} = change;
const to = CodeMirror.changeEnd(change);
const offscreen = from.line > viewTo || to.line < viewFrom;
if (offscreen) {
postponed.push(change);
continue;
}
if (from.line < viewFrom) {
postponed.push(Object.assign({}, change, {to: {line: viewFrom - 1}}));
change = Object.assign({}, change, {from: {line: viewFrom}});
}
if (to.line > viewTo) {
postponed.push(Object.assign({}, change, {from: {line: viewTo + 1}}));
change = Object.assign({}, change, {to: {line: viewTo}});
}
queue.push(change);
}
if (queue.length) colorizeChangesNow(state, queue);
if (postponed.length) setTimeout(colorizeChangesNow, 0, state, postponed, true);
}
function colorizeChangesNow(state, changes, canPostpone) {
const {cm} = state;
const {curOp} = cm;
if (!curOp) cm.startOperation();
state.now = performance.now();
const stopAt = canPostpone && state.now + TIME_BUDGET;
let stopped = null;
let change, changeFromLine;
let changeToLine = -1;
let queueIndex = -1;
changes = changes.sort((a, b) => a.from.line - b.from.line || a.from.ch - b.from.ch);
const first = changes[0].from.line;
const last = CodeMirror.changeEnd(changes[changes.length - 1]).line;
let line = state.line = first;
cm.doc.iter(first, last + 1, lineHandle => {
if (line > changeToLine) {
change = changes[++queueIndex];
if (!change) return true;
changeFromLine = change.from.line;
changeToLine = CodeMirror.changeEnd(change).line;
}
if (changeFromLine <= line && line <= changeToLine) {
state.line = line;
if (!lineHandle.styles) state.cm.getTokenTypeAt({line, ch: 0});
colorizeLineViaStyles(state, lineHandle);
}
if (canPostpone && (state.now = performance.now()) > stopAt) {
stopped = true;
return true;
}
line++;
});
updateMarkers(state);
if (!curOp) cm.endOperation();
if (stopped) {
const stoppedInChange = line >= changeFromLine && line < changeToLine;
if (stoppedInChange) {
changes.splice(0, queueIndex);
changes[0] = Object.assign({}, changes[0], {from: {line}});
} else {
changes.splice(0, queueIndex + 1);
}
state.colorizeTimer = setTimeout(colorizeChangesNow, 0, state, changes, true);
}
}
function colorizeLine(state, lineHandle) {
if (state.stopAt && (state.now = performance.now()) > state.stopAt) {
state.stopped = true;
return true;
}
const {text, styles} = lineHandle;
const {cm} = state;
if (state.inComment === null && !styles) {
cm.getTokenTypeAt({line: state.line, ch: 0});
colorizeLineViaStyles(state, lineHandle);
return;
}
if (styles) {
colorizeLineViaStyles(state, lineHandle);
return;
}
let cmtStart = 0;
let cmtEnd = 0;
do {
if (state.inComment) {
cmtEnd = text.indexOf('*/', cmtStart);
if (cmtEnd < 0) break;
state.inComment = false;
cmtEnd += 2;
}
cmtStart = (text.indexOf('/*', cmtEnd) + 1 || text.length + 1) - 1;
const chunk = !cmtEnd && cmtStart === text.length ? text : text.slice(cmtEnd, cmtStart);
RX_DETECT.lastIndex = 0;
const m = RX_DETECT.exec(chunk);
if (m) {
cmtEnd += m.index + m[1].length;
cm.getTokenTypeAt({line: state.line, ch: 0});
const {index} = getStyleAtPos({styles: lineHandle.styles, pos: cmtEnd}) || {};
colorizeLineViaStyles(state, lineHandle, Math.max(1, index || 0));
return;
}
state.inComment = cmtStart < text.length;
} while (state.inComment);
state.line++;
}
function colorizeLineViaStyles(state, lineHandle, styleIndex = 1) {
const {styles} = lineHandle;
let {text} = lineHandle;
let spanIndex = 0;
let uncommented = false;
let span, style, start, end, len, isHex, isFunc, color;
let {markedSpans} = lineHandle;
let spansSorted = false;
let spansZombies = markedSpans && markedSpans.length;
const spanGeneration = state.now;
// all comments may get blanked out in the loop
const endsWithComment = text.endsWith('*/');
for (let i = styleIndex; i + 1 < styles.length; i += 2) {
style = styles[i + 1];
const styleSupported = style && (
// old CodeMirror
style.includes('atom') || style.includes('keyword') ||
// new CodeMirror since 5.48
style.includes('variable callee')
);
if (!styleSupported) continue;
start = i > 2 ? styles[i - 2] : 0;
end = styles[i];
len = end - start;
isHex = text[start] === '#';
isFunc = text[end] === '(';
if (isFunc && (len < 3 || len > 4 || !testAt(RX_DETECT_FUNC, start, text))) continue;
if (isFunc && !uncommented) {
text = blankOutComments(text, start);
uncommented = true;
}
color = text.slice(start, isFunc ? text.indexOf(')', end) + 1 : end);
const j = !isHex && !isFunc && color.indexOf('!');
if (j > 0) {
color = color.slice(0, j);
end = start + j;
}
const spanState = markedSpans && checkSpan();
if (spanState === 'same') continue;
if (checkColor()) {
(spanState ? redeem : mark)(getSafeColorValue());
}
}
removeDeadSpans();
state.inComment = style && style.includes('comment') && !endsWithComment;
state.line++;
return;
function checkColor() {
if (isHex) return testAt(RX_COLOR.hex, 0, color);
if (!isFunc) return colorConverter.NAMED_COLORS.has(color.toLowerCase());
const colorLower = color.toLowerCase();
if (cache.has(colorLower)) return true;
const type = color.substr(0, 3);
const value = color.slice(len + 1, -1);
if (!testAt(RX_COLOR[type], 0, value)) return false;
cache.add(colorLower);
return true;
}
function mark(colorValue) {
const {line} = state;
state.cm.markText({line, ch: start}, {line, ch: end}, {
className: COLORVIEW_CLASS,
startStyle: COLORVIEW_SWATCH_CLASS,
css: COLORVIEW_SWATCH_CSS + colorValue,
color,
});
}
function getSafeColorValue() {
if (isHex && color.length !== 5 && color.length !== 9) return color;
if (!RX_COLOR.unsupported || !RX_COLOR.unsupported.test(color)) return color;
const value = colorConverter.parse(color);
return colorConverter.format(value, 'rgb');
}
// update or skip or delete existing swatches
function checkSpan() {
if (!spansSorted) {
markedSpans = markedSpans.sort((a, b) => a.from - b.from);
spansSorted = true;
}
while (spanIndex < markedSpans.length) {
span = markedSpans[spanIndex];
if (span.from <= start) {
spanIndex++;
} else {
break;
}
if (span.from === start && span.marker.className === COLORVIEW_CLASS) {
spansZombies--;
span.generation = spanGeneration;
const same = color === span.marker.color &&
(isFunc || /\W|^$/i.test(text.substr(start + color.length, 1)));
if (same) return 'same';
state.markersToRemove.push(span.marker);
return 'redeem';
}
}
}
function redeem(colorValue) {
spansZombies++;
state.markersToRemove.pop();
state.markersToRepaint.push(span);
span.to = end;
span.line = state.line;
span.index = spanIndex - 1;
span.marker.color = color;
span.marker.css = COLORVIEW_SWATCH_CSS + colorValue;
}
function removeDeadSpans() {
if (!spansZombies) return;
for (const span of markedSpans) {
if (span.generation !== spanGeneration &&
span.marker.className === COLORVIEW_CLASS) {
state.markersToRemove.push(span.marker);
}
}
}
}
//endregion
//region Popup
function openPopupForCursor(state, defaultColor) {
const {line, ch} = state.cm.getCursor();
const lineHandle = state.cm.getLineHandle(line);
const data = {
line, ch,
color: defaultColor,
isShortCut: true,
};
let found;
for (const {from, marker} of lineHandle.markedSpans || []) {
if (marker.className === COLORVIEW_CLASS &&
from <= ch && ch < from + marker.color.length) {
found = {color: marker.color, ch: from};
break;
}
}
found = found || findNearestColor(lineHandle, ch);
doOpenPopup(state, Object.assign(data, found));
if (found) highlightColor(state, data);
}
function openPopupForSwatch(state, swatch) {
const cm = state.cm;
const lineDiv = swatch.closest('div');
const {line: {markedSpans} = {}} = cm.display.renderedView.find(v => v.node === lineDiv) || {};
if (!markedSpans) return;
let swatchIndex = [...lineDiv.getElementsByClassName(COLORVIEW_SWATCH_CLASS)].indexOf(swatch);
for (const {marker} of markedSpans.sort((a, b) => a.from - b.from)) {
if (marker.className === COLORVIEW_CLASS && swatchIndex-- === 0) {
const data = Object.assign({color: marker.color}, marker.find().from);
highlightColor(state, data);
doOpenPopup(state, data);
break;
}
}
}
function doOpenPopup(state, data) {
const {left, bottom: top} = state.cm.charCoords(data, 'window');
state.popup.show(Object.assign(state.options.popup, data, {
top,
left,
cm: state.cm,
color: data.color,
prevColor: data.color || '',
callback: popupOnChange,
palette: makePalette(state),
paletteCallback,
}));
}
function popupOnChange(newColor) {
if (!newColor) {
return;
}
const {cm, line, ch, embedderCallback} = this;
const to = {line, ch: ch + this.prevColor.length};
const from = {line, ch};
if (cm.getRange(from, to) !== newColor) {
cm.replaceRange(newColor, from, to, '*colorpicker');
this.prevColor = newColor;
}
if (typeof embedderCallback === 'function') {
embedderCallback(this);
}
}
function makePalette({cm, options}) {
const palette = new Map();
let i = 0;
let nums;
cm.eachLine(({markedSpans}) => {
++i;
if (!markedSpans) return;
for (const {from, marker: m} of markedSpans) {
if (from == null || m.className !== COLORVIEW_CLASS) continue;
const color = m.color.toLowerCase();
nums = palette.get(color);
if (!nums) palette.set(color, (nums = []));
nums.push(i);
}
});
const res = [];
if (palette.size > 1 || nums && nums.length > 1) {
const old = new Map((options.popup.palette || []).map(el => [el.__color, el]));
for (const [color, data] of palette) {
const str = data.join(', ');
let el = old.get(color);
if (!el) {
el = document.createElement('div');
el.__color = color; // also used in color-picker.js
el.className = COLORVIEW_SWATCH_CLASS;
el.style.setProperty(`--${COLORVIEW_SWATCH_CLASS}`, color);
}
if (el.__str !== str) {
el.__str = str;
// break down long lists: 10 per line
el.title = `${color}\n${options.popup.paletteLine} ${
str.length > 50 ? str.replace(/([^,]+,\s){10}/g, '$&\n') : str
}`;
}
res.push(el);
}
res.push(Object.assign(document.createElement('span'), {
className: 'colorpicker-palette-hint',
title: options.popup.paletteHint,
textContent: '?',
}));
}
return res;
}
function paletteCallback(el) {
const {cm} = this;
const lines = el.title.split('\n')[1].match(/\d+/g).map(Number);
const i = lines.indexOf(cm.getCursor().line + 1) + 1;
const line = (lines[i] || lines[0]) - 1;
cm.jumpToPos({line, ch: 0});
}
//endregion
//region Utility
function updateMarkers(state) {
state.markersToRemove.forEach(m => m.clear());
state.markersToRemove.length = 0;
const {cm: {display: {viewFrom, viewTo, view}}} = state;
let viewIndex = 0;
let lineView = view[0];
let lineViewLine = viewFrom;
for (const {line, index, marker} of state.markersToRepaint) {
if (line < viewFrom || line >= viewTo) continue;
while (lineViewLine < line && lineView) {
lineViewLine += lineView.size;
lineView = view[++viewIndex];
}
if (!lineView) break;
const el = lineView.text.getElementsByClassName(COLORVIEW_SWATCH_CLASS)[index];
if (el) el.style = marker.css;
}
state.markersToRepaint.length = 0;
}
function findNearestColor({styles, text}, pos) {
const ALLOWED_STYLES = ['atom', 'keyword', 'callee', 'comment', 'string'];
let start, color, prevStart, prevColor, m;
RX_DETECT.lastIndex = Math.max(0, pos - 1000);
while ((m = RX_DETECT.exec(text))) {
start = m.index + m[1].length;
color = getColor(m[2].toLowerCase());
if (!color) continue;
if (start >= pos) break;
prevStart = start;
prevColor = color;
}
if (prevColor && pos - (prevStart + prevColor.length) < start - pos) {
return {color: prevColor, ch: prevStart};
} else if (color) {
return {color, ch: start};
}
function getColor(token) {
const {style} = getStyleAtPos({styles, pos: start + 1}) || {};
const allowed = !style || ALLOWED_STYLES.includes(style.split(' ', 1)[0]);
if (!allowed) return;
if (text[start + token.length] === '(') {
const tail = blankOutComments(text.slice(start), 0);
const color = tail.slice(0, tail.indexOf(')') + 1);
const type = color.slice(0, 3);
const value = color.slice(token.length + 1, -1);
return testAt(RX_COLOR[type], 0, value) && color;
}
return (token[0] === '#' || colorConverter.NAMED_COLORS.has(token)) && token;
}
}
function highlightColor(state, data) {
const {line} = data;
const {cm} = state;
const {viewFrom, viewTo} = cm.display;
if (line < viewFrom || line > viewTo) {
return;
}
const first = cm.charCoords(data, 'window');
const colorEnd = data.ch + data.color.length - 1;
let last = cm.charCoords({line, ch: colorEnd}, 'window');
if (last.top !== first.top) {
const funcEnd = data.ch + data.color.indexOf('(') - 1;
last = cm.charCoords({line, ch: funcEnd}, 'window');
}
const el = document.createElement('div');
const DURATION_SEC = 1;
el.style = `
position: fixed;
display: block;
top: ${first.top}px;
left: ${first.left}px;
width: ${last.right - first.left}px;
height: ${last.bottom - first.top}px;
animation: highlight ${DURATION_SEC}s;
`;
document.body.appendChild(el);
setTimeout(() => el.remove(), DURATION_SEC * 1000);
}
function testAt(rx, index, text) {
if (!rx) return false;
rx.lastIndex = index;
return rx.test(text);
}
function getStyleAtPos({
line,
styles = this.getLineHandle(line).styles,
pos,
}) {
if (pos < 0 || !styles) return;
const len = styles.length;
const end = styles[len - 2];
if (pos > end) return;
if (pos === end) {
return {
style: styles[len - 1],
index: len - 2,
};
}
const mid = (pos / end * (len - 1) & ~1) + 1;
let a = mid;
let b;
while (a > 1 && styles[a] > pos) {
b = a;
a = (a / 2 & ~1) + 1;
}
if (!b) b = mid;
while (b < len && styles[b] < pos) b = ((len + b) / 2 & ~1) + 1;
while (a < b - 3) {
const c = ((a + b) / 2 & ~1) + 1;
if (styles[c] > pos) b = c; else a = c;
}
while (a < len && styles[a] < pos) a += 2;
return {
style: styles[a + 1],
index: a,
};
}
function blankOutComments(text, start) {
const cmtStart = text.indexOf('/*', start);
return cmtStart < 0 ? text : (
text.slice(0, cmtStart) +
text.slice(cmtStart)
.replace(RX_COMMENT, s =>
SPACE1K.repeat(s.length / 1000 | 0) + SPACE1K.slice(0, s.length % 1000))
);
}
function hitTest({button, target, offsetX, offsetY}) {
if (button) return;
const swatch = target.closest('.' + COLORVIEW_CLASS);
if (!swatch) return;
const {left, width, height} = getComputedStyle(swatch, '::before');
const bounds = swatch.getBoundingClientRect();
const swatchClicked =
offsetX >= parseFloat(left) - 1 &&
offsetX <= parseFloat(left) + parseFloat(width) + 1 &&
offsetY >= parseFloat(height) / 2 - bounds.height / 2 - 1 &&
offsetY <= parseFloat(height) / 2 + bounds.height / 2 + 1;
return swatchClicked && swatch;
}
//endregion
})();