diff --git a/edit/applies-to-line-widget.js b/edit/applies-to-line-widget.js index df83dcfa..af0df7be 100644 --- a/edit/applies-to-line-widget.js +++ b/edit/applies-to-line-widget.js @@ -179,8 +179,11 @@ function createAppliesToLineWidget(cm) { fromLine = Math.max(fromLine || 0, cm.display.viewFrom); toLine = Math.min(toLine === null ? cm.doc.size : toLine, cm.display.viewTo || toLine); const visible = {fromLine, toLine}; + const {curOp} = cm; if (fromLine >= cm.display.viewFrom && toLine <= (cm.display.viewTo || toLine)) { - cm.operation(doUpdate); + if (!curOp) cm.startOperation(); + doUpdate(); + if (!curOp) cm.endOperation(); } if (changed.fromLine !== visible.fromLine || changed.toLine !== visible.toLine) { setTimeout(updateInvisible, 0, changed, visible); diff --git a/edit/colorpicker-helper.js b/edit/colorpicker-helper.js index 547bd1c1..d368a7fd 100644 --- a/edit/colorpicker-helper.js +++ b/edit/colorpicker-helper.js @@ -2,7 +2,6 @@ 'use strict'; onDOMscriptReady('/colorview.js').then(() => { - initOverlayHooks(); onDOMready().then(() => { $('#colorpicker-settings').onclick = configureColorpicker; }); @@ -23,7 +22,7 @@ onDOMscriptReady('/colorview.js').then(() => { defaults.colorpicker = { forceUpdate: editors.length > 0, tooltip: t('colorpickerTooltip'), - popupOptions: { + popup: { tooltipForSwitcher: t('colorpickerSwitchFormatTooltip'), hexUppercase: prefs.get('editor.colorpicker.hexUppercase'), hideDelay: 5000, @@ -35,7 +34,6 @@ onDOMscriptReady('/colorview.js').then(() => { }, }; } else { - CodeMirror.modeExtensions.css.unregisterColorviewHooks(); if (defaults.extraKeys) { delete defaults.extraKeys[keyName]; } @@ -114,47 +112,4 @@ onDOMscriptReady('/colorview.js').then(() => { } input.focus(); } - - function initOverlayHooks() { - const COLORVIEW_DISABLED_SUFFIX = ' colorview-disabled'; - const COLORVIEW_NEXT_DISABLED_SUFFIX = ' colorview-next-disabled'; - const originalAddOverlay = CodeMirror.prototype.addOverlay; - CodeMirror.prototype.addOverlay = addOverlayHook; - - function addOverlayHook(overlay) { - if (overlay.token !== tokenHook && ( - overlay === (this.state.matchHighlighter || {}).overlay || - overlay === (this.state.search || {}).overlay)) { - overlay.colopickerHelper = {token: overlay.token}; - overlay.token = tokenHook; - } - originalAddOverlay.apply(this, arguments); - } - - function tokenHook(stream) { - const style = this.colopickerHelper.token.apply(this, arguments); - if (!style) { - return style; - } - const {start, pos, lineOracle: {baseTokens}} = stream; - if (!baseTokens) { - return style; - } - for (let prev = 0, i = 1; i < baseTokens.length; i += 2) { - const end = baseTokens[i]; - if (prev <= start && start <= end) { - const base = baseTokens[i + 1]; - if (base && base.includes('colorview')) { - return style + - (start > prev ? COLORVIEW_DISABLED_SUFFIX : '') + - (pos < end ? COLORVIEW_NEXT_DISABLED_SUFFIX : ''); - } - } else if (end > pos) { - break; - } - prev = end; - } - return style; - } - } }); diff --git a/edit/source-editor.js b/edit/source-editor.js index 4be93c9e..da42c394 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -29,20 +29,15 @@ function createSourceEditor(style) { style = deepCopy(style); } - const cm = CodeMirror($('.single-editor')); + const cm = CodeMirror($('.single-editor'), {value: style.sourceCode}); editors.push(cm); + savedGeneration = cm.changeGeneration(); + + cm.operation(initAppliesToLineWidget); updateMeta().then(() => { initLint(); initLinterSwitch(); - - cm.setValue(style.sourceCode); - cm.clearHistory(); - cm.markClean(); - savedGeneration = cm.changeGeneration(); - initHooks(); - initAppliesToLineWidget(); - setTimeout(() => { if ((document.activeElement || {}).localName !== 'input') { cm.focus(); diff --git a/manage/config-dialog.css b/manage/config-dialog.css index 46f5d4c3..9a895641 100644 --- a/manage/config-dialog.css +++ b/manage/config-dialog.css @@ -11,11 +11,16 @@ } .config-heading { - float: right; - margin: -1.25rem 0 0 0; + top: -1em; + position: relative; + text-align: right; font-size: 0.9em; } +#stylus-popup .config-heading { + top: -.25em; +} + .config-body label { display: flex; padding: .75em 0; @@ -97,6 +102,10 @@ visibility: hidden; } +.config-reset-icon { + height: 16px; +} + .config-reset-icon .svg-icon { cursor: pointer; fill: #aaa; @@ -140,14 +149,22 @@ animation: fadein .5s; } -.cm-colorview::before, -.color-swatch { - width: var(--onoffswitch-width) !important; - height: 20px !important; +#message-box .colorview-swatch { + padding: 0; + box-sizing: content-box; } -.cm-colorview::before { - margin: 1px !important; +#message-box .colorview-swatch, +#message-box .colorview-swatch::before, +#message-box .colorview-swatch::after, +.color-swatch { + width: var(--onoffswitch-width); + height: 20px; + left: 0; + margin: 0; + border: 1px solid transparent; + box-sizing: content-box; + background-position: unset; } .color-swatch { @@ -157,6 +174,7 @@ border: 1px solid gray; cursor: pointer; opacity: 1; + z-index: 2; } .colorpicker-popup { diff --git a/manage/config-dialog.js b/manage/config-dialog.js index 503c5b39..04fe1128 100644 --- a/manage/config-dialog.js +++ b/manage/config-dialog.js @@ -210,7 +210,7 @@ function configDialog(style) { switch (va.type) { case 'color': children = [ - $create('.cm-colorview.config-value', [ + $create('.colorview-swatch.config-value', [ va.input = $create('a.color-swatch', { va, href: '#', diff --git a/vendor-overwrites/colorpicker/colorpicker.css b/vendor-overwrites/colorpicker/colorpicker.css index 4f6bb9d9..f3cb9b26 100644 --- a/vendor-overwrites/colorpicker/colorpicker.css +++ b/vendor-overwrites/colorpicker/colorpicker.css @@ -1,39 +1,38 @@ /* codemirror colorview */ -.cm-colorview { - position: relative; - white-space: nowrap; -} - -.cm-colorview:not(.cm-colorview-disabled)::before { - content: ""; +.colorview-swatch { + padding-left: 14px; position: relative; display: inline-block; - box-sizing: content-box; - margin: 0 3px; - width: 8px; - height: 8px; - background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC"); - background-repeat: repeat; -} -.CodeMirror-lint-mark-warning + .cm-colorview::before, -.cm-colorview-next-disabled + .cm-colorview::before { - content: none; } -.codemirror-colorview-background { +.colorview-swatch::before, +.colorview-swatch::after { + content: ""; position: absolute; + display: inline-block; left: 2px; - top: 2px; + top: 0; + bottom: 0; + margin: auto; width: 10px; height: 10px; box-sizing: border-box; - border: 1px solid #8e8e8e; - content: ""; - cursor: pointer; } -.codemirror-colorview-background:hover { +.colorview-swatch::before { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC"); + background-repeat: repeat; + background-position: center; +} + +.colorview-swatch::after { + border: 1px solid #8e8e8e; + cursor: pointer; + background-color: var(--colorview-swatch); +} + +.colorview-swatch:hover::after { border-color: #494949; } @@ -180,6 +179,7 @@ -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; + border: 1px solid var(--input-border-color); } .colorpicker-empty { diff --git a/vendor-overwrites/colorpicker/colorpicker.js b/vendor-overwrites/colorpicker/colorpicker.js index a6f8ce6f..59cc8095 100644 --- a/vendor-overwrites/colorpicker/colorpicker.js +++ b/vendor-overwrites/colorpicker/colorpicker.js @@ -1,6 +1,159 @@ /* global CodeMirror */ 'use strict'; +const NAMED_COLORS = new Map([ + ['transparent', 'rgba(0, 0, 0, 0)'], + // CSS4 named colors + ['aliceblue', '#f0f8ff'], + ['antiquewhite', '#faebd7'], + ['aqua', '#00ffff'], + ['aquamarine', '#7fffd4'], + ['azure', '#f0ffff'], + ['beige', '#f5f5dc'], + ['bisque', '#ffe4c4'], + ['black', '#000000'], + ['blanchedalmond', '#ffebcd'], + ['blue', '#0000ff'], + ['blueviolet', '#8a2be2'], + ['brown', '#a52a2a'], + ['burlywood', '#deb887'], + ['cadetblue', '#5f9ea0'], + ['chartreuse', '#7fff00'], + ['chocolate', '#d2691e'], + ['coral', '#ff7f50'], + ['cornflowerblue', '#6495ed'], + ['cornsilk', '#fff8dc'], + ['crimson', '#dc143c'], + ['cyan', '#00ffff'], + ['darkblue', '#00008b'], + ['darkcyan', '#008b8b'], + ['darkgoldenrod', '#b8860b'], + ['darkgray', '#a9a9a9'], + ['darkgrey', '#a9a9a9'], + ['darkgreen', '#006400'], + ['darkkhaki', '#bdb76b'], + ['darkmagenta', '#8b008b'], + ['darkolivegreen', '#556b2f'], + ['darkorange', '#ff8c00'], + ['darkorchid', '#9932cc'], + ['darkred', '#8b0000'], + ['darksalmon', '#e9967a'], + ['darkseagreen', '#8fbc8f'], + ['darkslateblue', '#483d8b'], + ['darkslategray', '#2f4f4f'], + ['darkslategrey', '#2f4f4f'], + ['darkturquoise', '#00ced1'], + ['darkviolet', '#9400d3'], + ['deeppink', '#ff1493'], + ['deepskyblue', '#00bfff'], + ['dimgray', '#696969'], + ['dimgrey', '#696969'], + ['dodgerblue', '#1e90ff'], + ['firebrick', '#b22222'], + ['floralwhite', '#fffaf0'], + ['forestgreen', '#228b22'], + ['fuchsia', '#ff00ff'], + ['gainsboro', '#dcdcdc'], + ['ghostwhite', '#f8f8ff'], + ['gold', '#ffd700'], + ['goldenrod', '#daa520'], + ['gray', '#808080'], + ['grey', '#808080'], + ['green', '#008000'], + ['greenyellow', '#adff2f'], + ['honeydew', '#f0fff0'], + ['hotpink', '#ff69b4'], + ['indianred', '#cd5c5c'], + ['indigo', '#4b0082'], + ['ivory', '#fffff0'], + ['khaki', '#f0e68c'], + ['lavender', '#e6e6fa'], + ['lavenderblush', '#fff0f5'], + ['lawngreen', '#7cfc00'], + ['lemonchiffon', '#fffacd'], + ['lightblue', '#add8e6'], + ['lightcoral', '#f08080'], + ['lightcyan', '#e0ffff'], + ['lightgoldenrodyellow', '#fafad2'], + ['lightgray', '#d3d3d3'], + ['lightgrey', '#d3d3d3'], + ['lightgreen', '#90ee90'], + ['lightpink', '#ffb6c1'], + ['lightsalmon', '#ffa07a'], + ['lightseagreen', '#20b2aa'], + ['lightskyblue', '#87cefa'], + ['lightslategray', '#778899'], + ['lightslategrey', '#778899'], + ['lightsteelblue', '#b0c4de'], + ['lightyellow', '#ffffe0'], + ['lime', '#00ff00'], + ['limegreen', '#32cd32'], + ['linen', '#faf0e6'], + ['magenta', '#ff00ff'], + ['maroon', '#800000'], + ['mediumaquamarine', '#66cdaa'], + ['mediumblue', '#0000cd'], + ['mediumorchid', '#ba55d3'], + ['mediumpurple', '#9370db'], + ['mediumseagreen', '#3cb371'], + ['mediumslateblue', '#7b68ee'], + ['mediumspringgreen', '#00fa9a'], + ['mediumturquoise', '#48d1cc'], + ['mediumvioletred', '#c71585'], + ['midnightblue', '#191970'], + ['mintcream', '#f5fffa'], + ['mistyrose', '#ffe4e1'], + ['moccasin', '#ffe4b5'], + ['navajowhite', '#ffdead'], + ['navy', '#000080'], + ['oldlace', '#fdf5e6'], + ['olive', '#808000'], + ['olivedrab', '#6b8e23'], + ['orange', '#ffa500'], + ['orangered', '#ff4500'], + ['orchid', '#da70d6'], + ['palegoldenrod', '#eee8aa'], + ['palegreen', '#98fb98'], + ['paleturquoise', '#afeeee'], + ['palevioletred', '#db7093'], + ['papayawhip', '#ffefd5'], + ['peachpuff', '#ffdab9'], + ['peru', '#cd853f'], + ['pink', '#ffc0cb'], + ['plum', '#dda0dd'], + ['powderblue', '#b0e0e6'], + ['purple', '#800080'], + ['rebeccapurple', '#663399'], + ['red', '#ff0000'], + ['rosybrown', '#bc8f8f'], + ['royalblue', '#4169e1'], + ['saddlebrown', '#8b4513'], + ['salmon', '#fa8072'], + ['sandybrown', '#f4a460'], + ['seagreen', '#2e8b57'], + ['seashell', '#fff5ee'], + ['sienna', '#a0522d'], + ['silver', '#c0c0c0'], + ['skyblue', '#87ceeb'], + ['slateblue', '#6a5acd'], + ['slategray', '#708090'], + ['slategrey', '#708090'], + ['snow', '#fffafa'], + ['springgreen', '#00ff7f'], + ['steelblue', '#4682b4'], + ['tan', '#d2b48c'], + ['teal', '#008080'], + ['thistle', '#d8bfd8'], + ['tomato', '#ff6347'], + ['turquoise', '#40e0d0'], + ['violet', '#ee82ee'], + ['wheat', '#f5deb3'], + ['white', '#ffffff'], + ['whitesmoke', '#f5f5f5'], + ['yellow', '#ffff00'], + ['yellowgreen', '#9acd32'], +]); + (window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () { const cm = this; const CSS_PREFIX = 'colorpicker-'; @@ -57,6 +210,8 @@ hide, setColor, getColor, + stringToColor, + colorToString, options, }; return PUBLIC_API; @@ -554,11 +709,16 @@ function onMouseUp(event) { releaseMouse(event, ['saturation', 'hue', 'opacity']); + if (onMouseDown.outsideClick) { + if (!prevFocusedElement) hide(); + } } function onMouseDown(event) { - if (event.button === 0 && !event.target.closest('.colorpicker-popup')) { - hide(); + onMouseDown.outsideClick = !event.button && !event.target.closest('.colorpicker-popup'); + if (onMouseDown.outsideClick) { + prevFocusedElement = null; + captureMouse(event); } } @@ -607,6 +767,10 @@ function onCloseRequest(event) { if (event.detail !== PUBLIC_API) { hide(); + } else if (!prevFocusedElement) { + // we're between mousedown and mouseup and colorview wants to re-open us in this cm + // so we'll prevent onMouseUp from hiding us to avoid flicker + prevFocusedElement = cm.display.input; } } @@ -689,7 +853,7 @@ function unregisterEvents() { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('mousedown', onMouseDown, true); - window.removeEventListener('close-colorpicker-popup', hide, true); + window.removeEventListener('close-colorpicker-popup', onCloseRequest, true); $root.removeEventListener('mouseleave', snooze); $root.removeEventListener('mouseenter', stopSnoozing); $root.removeEventListener('input', setFromInputs); @@ -709,9 +873,13 @@ //endregion //region Color conversion utilities - function colorToString({r, g, b, h, s, l, a}, type = currentFormat) { - a = alphaToString(a); + function colorToString(color, type = currentFormat) { + const a = alphaToString(color.a); const hasA = Boolean(a); + if (type === 'rgb' && color.type === 'hsl') { + color = HSVtoRGB(HSLtoHSV(color)); + } + const {r, g, b, h, s, l} = color; switch (type) { case 'hex': { const rgbStr = (0x1000000 + (r << 16) + (g << 8) + (b | 0)).toString(16).slice(1); @@ -721,8 +889,8 @@ } case 'rgb': return hasA ? - `rgba(${r}, ${g}, ${b}, ${a})` : - `rgb(${r}, ${g}, ${b})`; + `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})` : + `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; case 'hsl': return hasA ? `hsla(${h}, ${s}%, ${l}%, ${a})` : @@ -731,25 +899,47 @@ } function stringToColor(str) { - if (typeof str !== 'string') { - return; - } + if (typeof str !== 'string') return; str = str.trim(); - if (str.startsWith('rgb')) { - const [r, g, b, a = 1] = str.replace(/rgba?\(|\)/g, '').split(',').map(parseFloat); - return {type: 'rgb', r, g, b, a}; + if (!str) return; + + if (str[0] !== '#' && !str.includes('(')) { + str = NAMED_COLORS.get(str); + if (!str) return; } - if (str.startsWith('hsl')) { - const [h, s, l, a = 1] = str.replace(/hsla?\(|\)/g, '').split(',').map(parseFloat); - return {type: 'hsl', h, s, l, a}; - } - if (str.startsWith('#')) { + + if (str[0] === '#') { str = str.slice(1); const [r, g, b, a = 255] = str.length <= 4 ? str.match(/(.)/g).map(c => parseInt(c + c, 16)) : str.match(/(..)/g).map(c => parseInt(c, 16)); return {type: 'hex', r, g, b, a: a === 255 ? undefined : a / 255}; } + + const [, type, value] = str.match(/^(rgb|hsl)a?\((.*?)\)|$/i); + if (!type) return; + + const comma = value.includes(',') && !value.includes('/'); + const num = value.split(comma ? /\s*,\s*/ : /\s+(?!\/)|\s*\/\s*/); + if (num.length < 3 || num.length > 4) return; + + let a = !num[3] ? 1 : parseFloat(num[3]) / (num[3].endsWith('%') ? 100 : 1); + if (isNaN(a)) a = 1; + + const first = num[0]; + if (/rgb/i.test(type)) { + const k = first.endsWith('%') ? 2.55 : 1; + const [r, g, b] = num.map(s => parseFloat(s) * k); + return {type: 'rgb', r, g, b, a}; + } else { + let h = parseFloat(first); + if (first.endsWith('grad')) h *= 360 / 400; + else if (first.endsWith('rad')) h *= 180 / Math.PI; + else if (first.endsWith('turn')) h *= 360; + const s = parseFloat(num[1]); + const l = parseFloat(num[2]); + return {type: 'hsl', h, s, l, a}; + } } function constrainHue(h) { diff --git a/vendor-overwrites/colorpicker/colorview.js b/vendor-overwrites/colorpicker/colorview.js index 59aa3577..48fe3bb0 100644 --- a/vendor-overwrites/colorpicker/colorview.js +++ b/vendor-overwrites/colorpicker/colorview.js @@ -1,527 +1,676 @@ -/* global CodeMirror */ +/* global CodeMirror NAMED_COLORS */ 'use strict'; (() => { - const OWN_TOKEN_NAME = 'colorview'; - const OWN_TOKEN_CLASS = 'cm-' + OWN_TOKEN_NAME; - const OWN_BACKGROUND_CLASS = 'codemirror-colorview-background'; + //region Constants - const DISABLED_TOKEN_NAME = 'colorview-disabled'; - const DISABLED_NEXT_TOKEN_NAME = 'colorview-next-disabled'; - const DISABLED_TOKEN_CLASS = 'cm-' + DISABLED_TOKEN_NAME; - const DISABLED_NEXT_TOKEN_CLASS = 'cm-' + DISABLED_NEXT_TOKEN_NAME; + const COLORVIEW_CLASS = 'colorview'; + const COLORVIEW_SWATCH_CLASS = COLORVIEW_CLASS + '-swatch'; + const COLORVIEW_SWATCH_CSS = `--${COLORVIEW_SWATCH_CLASS}:`; - const HOOKED_TOKEN = new Map([ - ['atom', colorizeAtom], - ['keyword', colorizeKeyword], - ].map(([name, fn]) => [name, {override: name + ' ' + OWN_TOKEN_NAME, process: fn}])); + const CLOSE_POPUP_EVENT = 'close-colorpicker-popup'; - const NAMED_COLORS = getNamedColorsMap(); - const TRANSPARENT = { - color: 'transparent', - colorValue: 'rgba(0, 0, 0, 0)', // as per the CSS spec - }; + const RXS_NUM = /\s*(\d+\.?\d*|\d*\.\d+)(?:e\d+)?/.source; const RX_COLOR = { - hex: /#(?:[a-f\d]{3,4}|[a-f\d]{6}|[a-f\d]{8})\b/yi, - rgb: /rgb\((?:\s*\d{1,3}\s*,\s*){2}\d{1,3}\s*\)/yi, - rgba: /rgba\((?:\s*\d{1,3}\s*,\s*){3}\d*\.?\d+\s*\)/yi, - hsl: /hsl\(\s*(?:-?\d+|-?\d*\.\d+)\s*(?:,\s*(?:-?\d+|-?\d*\.\d+)%\s*){2}\)/yi, - hsla: /hsla\(\s*(?:-?\d+|-?\d*\.\d+)\s*(?:,\s*(?:-?\d+|-?\d*\.\d+)%\s*){2},\s*(?:-?\d+|-?\d*\.\d+)\s*\)/yi, - }; + hex: /#(?:[a-f\d]{3}(?:[a-f\d](?:[a-f\d]{2}){0,2})?)\b/iy, - const CodeMirrorEvents = { - update(cm) { - if (cm.state.colorpicker.cache.size) { - renderVisibleTokens(cm); - } + 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'), + }; + const RX_DETECT = new RegExp('(^|[\\s():,/])(' + RX_COLOR.hex.source + + '|(?:rgb|hsl)a?(?=\\()|(?:' + [...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); }, - keyup(cm) { - const popup = cm.state.colorpicker.popup; - if (popup && popup.options.isShortCut === false) { - popup.hide(); - } + update(cm) { + const textHeight = cm.display.cachedTextHeight; + const height = cm.display.lastWrapHeight; + if (!height || !textHeight) return; + const numLines = Math.ceil(height / textHeight); + if (numLines >= maxRenderChunkSize) return; + maxRenderChunkSize = numLines; + cm.off('update', CM_EVENTS.update); }, mousedown(cm, event) { - const self = cm.state.colorpicker; - const isMarker = event.button === 0 && event.target.classList.contains(OWN_BACKGROUND_CLASS); - window.dispatchEvent(new CustomEvent('close-colorpicker-popup', {detail: isMarker && self.popup})); - if (isMarker) { + const state = cm.state.colorpicker; + const swatch = hitTest(event); + dispatchEvent(new CustomEvent(CLOSE_POPUP_EVENT, { + detail: swatch && state.popup, + })); + if (swatch) { event.preventDefault(); - self.openPopupForToken(event.target.parentNode); + openPopupForSwatch(state, swatch); } }, }; - function registerEvents(cm) { - Object.keys(CodeMirrorEvents).forEach(name => cm.on(name, CodeMirrorEvents[name])); - } + //endregion + //region ColorSwatch - function unregisterEvents(cm) { - Object.keys(CodeMirrorEvents).forEach(name => cm.off(name, CodeMirrorEvents[name])); - } + const cache = new Set(); - function registerHooks() { - const mx = CodeMirror.modeExtensions.css; - if (!mx || mx.token !== colorizeToken) { - CodeMirror.extendMode('css', {token: colorizeToken}); - CodeMirror.extendMode('stylus', {token: colorizeToken}); - CodeMirror.modeExtensions.css.registerColorviewHooks = registerHooks; - CodeMirror.modeExtensions.css.unregisterColorviewHooks = unregisterHooks; - } - } - - function unregisterHooks() { - for (const name in CodeMirror.modeExtensions) { - const mx = CodeMirror.modeExtensions[name]; - if (mx && mx.token === colorizeToken) { - delete mx.token; - } - } - } - - function resetMode(cm) { - cm.setOption('mode', cm.getMode().name); - } - - function colorizeToken(stream, state) { - const token = this._token.apply(this, arguments); - const hookedToken = token && HOOKED_TOKEN.get(token); - if (!token || !hookedToken) { - return token; - } - const data = state.colorpicker = (state.colorpicker || {}); - const cache = data.cache = (data.cache || stream.lineOracle.doc.cm.state.colorpicker.cache); - const string = stream.string; - const sameString = string === data.lastString; - - data.lastString = string; - - let lineCache = data.lineCache = (sameString ? data.lineCache : cache.get(string)); - if (lineCache && lineCache.get(stream.start)) { - return hookedToken.override; - } - - const color = hookedToken.process(stream); - if (color) { - if (!lineCache) { - lineCache = data.lineCache = new Map(); - cache.set(string, lineCache); - } - lineCache.set(stream.start, color); - lineCache.set('lastAccessTime', performance.now()); - return hookedToken.override; - } - - return token; - } - - function colorizeAtom(stream) { - const {start, pos, string} = stream; - const c1 = string.charAt(start); - if ((c1 === 't' || c1 === 'T') && string.slice(start, pos).toLowerCase() === 'transparent') { - return TRANSPARENT; - } - const maybeHex = c1 === '#'; - const s = !maybeHex && string.charAt(pos) === '(' && string.slice(start, pos).toLowerCase(); - if (maybeHex || (s === 'rgb' || s === 'rgba' || s === 'hsl' || s === 'hsla')) { - const rx = maybeHex ? RX_COLOR.hex : RX_COLOR[s]; - rx.lastIndex = start; - const match = rx.exec(string); - return match && {color: match[0]}; - } - } - - function colorizeKeyword(stream) { - const {start, pos, string} = stream; - if (string.charAt(start) !== '!') { - const color = string.slice(start, pos); - const colorValue = NAMED_COLORS.get(color.toLowerCase()); - return colorValue ? {color, colorValue} : colorizeAtom(stream); - } - } - - function renderVisibleTokens(cm) { - const {cache, options} = cm.state.colorpicker; - let line = cm.display.viewFrom - 1; - for (const {line: lineHandle, text} of cm.display.renderedView) { - if (!lineHandle.parent) { - continue; - } - line++; - const styles = lineHandle.styles; - if (!styles) { - continue; - } - const lineCache = cache.get(lineHandle.text); - if (!lineCache) { - continue; - } - let lineCacheAlive = false; - let elementIndex = 0; - let elements, el, token; - - for (let i = 1; i < styles.length; i += 2) { - if (token && token.includes(DISABLED_NEXT_TOKEN_NAME)) { - token = styles[i + 1]; - elementIndex++; - i += 2; - continue; - } - token = styles[i + 1]; - if (!token || !token.includes(OWN_TOKEN_NAME)) { - continue; - } - if (token.includes(DISABLED_TOKEN_NAME)) { - elementIndex++; - continue; - } - - const start = styles[i - 2] || 0; - const data = lineCache.get(start); - if (!data) { - continue; - } - lineCacheAlive = true; - - if (!elements) elements = text.getElementsByClassName(OWN_TOKEN_CLASS); - do { - el = elements[elementIndex]; - elementIndex += el && el.classList.contains(DISABLED_NEXT_TOKEN_CLASS) ? 2 : 1; - } while (el && el.classList.contains(DISABLED_TOKEN_CLASS)); - - if (!el || (el.colorpickerData || {}).color === data.color) { - continue; - } - - //////// yay we finally found something to render - el.colorpickerData = Object.assign({line, ch: start}, data); - let bg = el.firstElementChild; - if (!bg) { - bg = document.createElement('div'); - bg.className = OWN_BACKGROUND_CLASS; - bg.title = options.tooltip; - } - bg.style.setProperty('background-color', data.color, 'important'); - if (!bg.parentNode) el.appendChild(bg); - } - - if (lineCacheAlive) { - lineCache.set('lastAccessTime', performance.now()); - } - } - trimCache(cm); - } - - function trimCache(cm, debounced) { - if (!debounced) { - clearTimeout(trimCache.timer); - trimCache.timer = setTimeout(trimCache, 20e3, cm, true); - return; - } - const cutoff = performance.now() - 60e3; - const {cache} = cm.state.colorpicker; - const textToKeep = new Set(); - cm.doc.iter(({text}) => textToKeep.add(text)); - for (const [text, lineCache] of cache.entries()) { - if (lineCache.get('lastAccessTime') < cutoff && !textToKeep.has(text)) { - cache.delete(text); - } - } - } - - function parseColorAtCursor(lineText, lineTextLC = lineText.toLowerCase(), ch) { - const iHex = lineTextLC.lastIndexOf('#', ch); - const iParen = ( - lineTextLC.lastIndexOf('(', ch) + 1 || - lineTextLC.indexOf('(', ch) + 1 - ) - 1; - let start = Math.max(iHex, iParen); - let match, end, color, colorValue; - if (start >= 0) { - if (start === iHex) { - match = RX_COLOR.hex; - } else { - const tokenLen = lineTextLC.charAt(start - 1) === 'a' ? 4 : 3; - start -= tokenLen; - match = RX_COLOR[lineTextLC.substr(start, tokenLen)]; - } - if (match) { - match.lastIndex = start; - ([color] = match.exec(lineText) || []); - } - } else { - const isLetterAt = (i, code = lineTextLC.charCodeAt(i)) => code >= 97 && code <= 122; - for (start = ch; isLetterAt(start); start--) {} // eslint-disable-line no-empty - for (end = ch; isLetterAt(end); end++) {} // eslint-disable-line no-empty - start++; - (color = lineTextLC.slice(start, end)); - colorValue = NAMED_COLORS.get(color); - if (!colorValue) { - start = ch; - color = ''; - } - } - return color && {ch: start, color, colorValue}; - } - - function getNamedColorsMap() { - return new Map([ - ['aliceblue', '#f0f8ff'], - ['antiquewhite', '#faebd7'], - ['aqua', '#00ffff'], - ['aquamarine', '#7fffd4'], - ['azure', '#f0ffff'], - ['beige', '#f5f5dc'], - ['bisque', '#ffe4c4'], - ['black', '#000000'], - ['blanchedalmond', '#ffebcd'], - ['blue', '#0000ff'], - ['blueviolet', '#8a2be2'], - ['brown', '#a52a2a'], - ['burlywood', '#deb887'], - ['cadetblue', '#5f9ea0'], - ['chartreuse', '#7fff00'], - ['chocolate', '#d2691e'], - ['coral', '#ff7f50'], - ['cornflowerblue', '#6495ed'], - ['cornsilk', '#fff8dc'], - ['crimson', '#dc143c'], - ['cyan', '#00ffff'], - ['darkblue', '#00008b'], - ['darkcyan', '#008b8b'], - ['darkgoldenrod', '#b8860b'], - ['darkgray', '#a9a9a9'], - ['darkgreen', '#006400'], - ['darkgrey', '#a9a9a9'], - ['darkkhaki', '#bdb76b'], - ['darkmagenta', '#8b008b'], - ['darkolivegreen', '#556b2f'], - ['darkorange', '#ff8c00'], - ['darkorchid', '#9932cc'], - ['darkred', '#8b0000'], - ['darksalmon', '#e9967a'], - ['darkseagreen', '#8fbc8f'], - ['darkslateblue', '#483d8b'], - ['darkslategray', '#2f4f4f'], - ['darkslategrey', '#2f4f4f'], - ['darkturquoise', '#00ced1'], - ['darkviolet', '#9400d3'], - ['deeppink', '#ff1493'], - ['deepskyblue', '#00bfff'], - ['dimgray', '#696969'], - ['dimgrey', '#696969'], - ['dodgerblue', '#1e90ff'], - ['firebrick', '#b22222'], - ['floralwhite', '#fffaf0'], - ['forestgreen', '#228b22'], - ['fuchsia', '#ff00ff'], - ['gainsboro', '#dcdcdc'], - ['ghostwhite', '#f8f8ff'], - ['gold', '#ffd700'], - ['goldenrod', '#daa520'], - ['gray', '#808080'], - ['green', '#008000'], - ['greenyellow', '#adff2f'], - ['grey', '#808080'], - ['honeydew', '#f0fff0'], - ['hotpink', '#ff69b4'], - ['indianred', '#cd5c5c'], - ['indigo', '#4b0082'], - ['ivory', '#fffff0'], - ['khaki', '#f0e68c'], - ['lavender', '#e6e6fa'], - ['lavenderblush', '#fff0f5'], - ['lawngreen', '#7cfc00'], - ['lemonchiffon', '#fffacd'], - ['lightblue', '#add8e6'], - ['lightcoral', '#f08080'], - ['lightcyan', '#e0ffff'], - ['lightgoldenrodyellow', '#fafad2'], - ['lightgray', '#d3d3d3'], - ['lightgreen', '#90ee90'], - ['lightgrey', '#d3d3d3'], - ['lightpink', '#ffb6c1'], - ['lightsalmon', '#ffa07a'], - ['lightseagreen', '#20b2aa'], - ['lightskyblue', '#87cefa'], - ['lightslategray', '#778899'], - ['lightslategrey', '#778899'], - ['lightsteelblue', '#b0c4de'], - ['lightyellow', '#ffffe0'], - ['lime', '#00ff00'], - ['limegreen', '#32cd32'], - ['linen', '#faf0e6'], - ['magenta', '#ff00ff'], - ['maroon', '#800000'], - ['mediumaquamarine', '#66cdaa'], - ['mediumblue', '#0000cd'], - ['mediumorchid', '#ba55d3'], - ['mediumpurple', '#9370db'], - ['mediumseagreen', '#3cb371'], - ['mediumslateblue', '#7b68ee'], - ['mediumspringgreen', '#00fa9a'], - ['mediumturquoise', '#48d1cc'], - ['mediumvioletred', '#c71585'], - ['midnightblue', '#191970'], - ['mintcream', '#f5fffa'], - ['mistyrose', '#ffe4e1'], - ['moccasin', '#ffe4b5'], - ['navajowhite', '#ffdead'], - ['navy', '#000080'], - ['oldlace', '#fdf5e6'], - ['olive', '#808000'], - ['olivedrab', '#6b8e23'], - ['orange', '#ffa500'], - ['orangered', '#ff4500'], - ['orchid', '#da70d6'], - ['palegoldenrod', '#eee8aa'], - ['palegreen', '#98fb98'], - ['paleturquoise', '#afeeee'], - ['palevioletred', '#db7093'], - ['papayawhip', '#ffefd5'], - ['peachpuff', '#ffdab9'], - ['peru', '#cd853f'], - ['pink', '#ffc0cb'], - ['plum', '#dda0dd'], - ['powderblue', '#b0e0e6'], - ['purple', '#800080'], - ['rebeccapurple', '#663399'], - ['red', '#ff0000'], - ['rosybrown', '#bc8f8f'], - ['royalblue', '#4169e1'], - ['saddlebrown', '#8b4513'], - ['salmon', '#fa8072'], - ['sandybrown', '#f4a460'], - ['seagreen', '#2e8b57'], - ['seashell', '#fff5ee'], - ['sienna', '#a0522d'], - ['silver', '#c0c0c0'], - ['skyblue', '#87ceeb'], - ['slateblue', '#6a5acd'], - ['slategray', '#708090'], - ['slategrey', '#708090'], - ['snow', '#fffafa'], - ['springgreen', '#00ff7f'], - ['steelblue', '#4682b4'], - ['tan', '#d2b48c'], - ['teal', '#008080'], - ['thistle', '#d8bfd8'], - ['tomato', '#ff6347'], - ['turquoise', '#40e0d0'], - ['violet', '#ee82ee'], - ['wheat', '#f5deb3'], - ['white', '#ffffff'], - ['whitesmoke', '#f5f5f5'], - ['yellow', '#ffff00'], - ['yellowgreen', '#9acd32'], - ]); - } - - class ColorMarker { - constructor(cm, { - tooltip = 'Open color picker', - popupOptions = {}, - colorpicker, - forceUpdate, - } = {}) { + class ColorSwatch { + constructor(cm, options) { this.cm = cm; - this.options = { - tooltip, - popup: Object.assign({ - hideDelay: 2000, - hexUppercase: false, - tooltipForSwitcher: 'Switch formats: HEX -> RGB -> HSL', - }, popupOptions), - }; - this.popup = cm.colorpicker ? cm.colorpicker() : colorpicker; - this.cache = new Map(); - registerHooks(cm); - registerEvents(cm); - if (forceUpdate) { - resetMode(cm); + this.options = options; + this.popup = cm.colorpicker(); + this.markersToRemove = []; + this.markersToRepaint = []; + 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() { - unregisterHooks(this.cm); - unregisterEvents(this.cm); - resetMode(this.cm); - this.cm.state.colorpicker = null; - } - - openPopup(color) { - // eslint-disable-next-line prefer-const - let {line, ch} = this.cm.getCursor(); - const lineText = this.cm.getLine(line); - const lineTextLC = lineText.toLowerCase(); - const atImportant = lineTextLC.lastIndexOf('!important', ch); - if (atImportant >= Math.max(0, ch - '!important'.length)) { - ch -= Math.max(0, ch - atImportant); - } - const data = {line, ch, colorValue: color, isShortCut: true}; - const lineCache = this.cm.state.colorpicker.cache.get(lineText); - if (lineCache) { - for (const [start, {color, colorValue = color}] of lineCache.entries()) { - // one entry is for lastAccessTime - if (lineCache.size === 2 || start <= ch && ch <= start + color.length) { - Object.assign(data, {ch: start, color, colorValue}); - this.openPopupForToken({colorpickerData: data}); - return; - } - } - } - Object.assign(data, parseColorAtCursor(lineText, lineTextLC, ch)); - this.openPopupForToken({colorpickerData: data}); - } - - openPopupForToken({colorpickerData: data}) { - if (this.popup) { - const {left, bottom: top} = this.cm.charCoords(data, 'window'); - this.popup.show(Object.assign(this.options.popup, data, { - top, - left, - cm: this.cm, - color: data.colorValue || data.color, - prevColor: data.color || '', - isShortCut: false, - callback: ColorMarker.popupOnChange, - })); - } - } - - closePopup() { - if (this.popup) { - this.popup.hide(); - } - } - - static popupOnChange(newColor) { - if (!newColor) { - return; - } - const {cm, line, ch, embedderCallback} = this; - const to = {line, ch: ch + this.prevColor.length}; - if (cm.getRange(this, to) !== newColor) { - cm.replaceRange(newColor, this, to, '*colorpicker'); - this.prevColor = newColor; - } - if (typeof embedderCallback === 'function') { - embedderCallback(this); - } + 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) { - registerHooks(); - cm.state.colorpicker = new ColorMarker(cm, value); + cm.state.colorpicker = new ColorSwatch(cm, value); } }); - // initial runMode is performed by CodeMirror before setting our option - // so we register the hooks right away (the cost of always loading colorview is ~1ms for >200ms) - registerHooks(); + CodeMirror.prototype.getStyleAtPos = getStyleAtPos; + + return; + + //endregion + //region Colorizing + + function colorizeAll(state) { + const {cm} = state; + const {curOp} = cm; + if (!curOp) cm.startOperation(); + + const viewFrom = cm.display.viewFrom; + const viewTo = (cm.display.viewTo || maxRenderChunkSize - 1) + 1; + + state.line = viewFrom; + state.inComment = null; + 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.colorizeTimer = setTimeout(colorizeInvisible, 100, state, viewFrom, viewTo, 0); + } + } + + + function colorizeInvisible(state, viewFrom, viewTo, line) { + const {cm} = state; + const {curOp} = cm; + if (!curOp) cm.startOperation(); + + state.stopAt = performance.now() + TIME_BUDGET; + state.stopped = null; + + // before the visible range + if (viewFrom) { + state.line = line; + cm.doc.iter(line, viewFrom, lineHandle => colorizeLine(state, lineHandle)); + } + + // after the visible range + if (!state.stopped && viewTo < cm.doc.size) { + state.line = viewTo; + cm.doc.iter(viewTo, cm.doc.size, lineHandle => colorizeLine(state, lineHandle)); + } + + updateMarkers(state); + if (!curOp) cm.endOperation(); + + if (state.stopped) { + state.colorizeTimer = setTimeout(colorizeInvisible, 0, state, viewFrom, viewFrom, state.line); + } + } + + + function colorizeChanges(state, changes) { + 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(); + + const stopAt = canPostpone && performance.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 && 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 && 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; + + for (let i = styleIndex; i + 1 < styles.length; i += 2) { + style = styles[i + 1]; + const styleSupported = style && (style.includes('atom') || style.includes('keyword')); + 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 spanState = markedSpans && checkSpan(); + if (spanState === 'same') continue; + if (checkColor()) { + (spanState ? redeem : mark)(getSafeColorValue()); + } + } + + removeDeadSpans(); + + state.inComment = style && style.includes('comment') && !text.endsWith('*/'); + state.line++; + return; + + function checkColor() { + if (isHex) return testAt(RX_COLOR.hex, 0, color); + if (!isFunc) return 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 (!isFunc || !RX_COLOR.unsupported.test(color)) return color; + const value = state.popup.stringToColor(color); + return state.popup.colorToString(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) { + 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) { + 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() { + while (markedSpans && spanIndex < markedSpans.length) { + span = markedSpans[spanIndex++]; + if (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 || '', + isShortCut: false, + callback: popupOnChange, + })); + } + + + function popupOnChange(newColor) { + if (!newColor) { + return; + } + const {cm, line, ch, embedderCallback} = this; + const to = {line, ch: ch + this.prevColor.length}; + if (cm.getRange(this, to) !== newColor) { + cm.replaceRange(newColor, this, to, '*colorpicker'); + this.prevColor = newColor; + } + if (typeof embedderCallback === 'function') { + embedderCallback(this); + } + } + + //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', '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 = ALLOWED_STYLES.includes(style); + 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] === '#' || 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) return; + const len = styles.length; + const end = styles[len - 2]; + if (pos > end) return; + 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, '::after'); + 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 })();