🎨 Update
This commit is contained in:
@@ -4,10 +4,7 @@ import '@/assets/styles/index.css';
|
||||
import {createPinia} from 'pinia';
|
||||
import i18n from './i18n';
|
||||
import router from './router';
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
const app = createApp(App);
|
||||
app.use(pinia)
|
||||
app.use(i18n);
|
||||
|
@@ -24,9 +24,11 @@ import {defaultKeymap, history, historyKeymap,} from '@codemirror/commands';
|
||||
import {highlightSelectionMatches} from '@codemirror/search';
|
||||
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
|
||||
import {lintKeymap} from '@codemirror/lint';
|
||||
import { vscodeSearch, customSearchKeymap, searchVisibilityField } from '../plugins/vscodeSearch';
|
||||
import { vscodeSearch, customSearchKeymap, searchVisibilityField } from './vscodeSearch';
|
||||
|
||||
import { hyperLink } from './hyperlink';
|
||||
import { color } from './colorSelector';
|
||||
|
||||
import { hyperLink } from '../plugins/hyperlink';
|
||||
// 基本编辑器设置,包含常用扩展
|
||||
export const createBasicSetup = (): Extension[] => {
|
||||
return [
|
||||
@@ -35,6 +37,7 @@ export const createBasicSetup = (): Extension[] => {
|
||||
searchVisibilityField,
|
||||
|
||||
hyperLink,
|
||||
color,
|
||||
|
||||
// 基础UI
|
||||
lineNumbers(),
|
||||
@@ -67,7 +70,7 @@ export const createBasicSetup = (): Extension[] => {
|
||||
|
||||
// 键盘映射
|
||||
keymap.of([
|
||||
...customSearchKeymap as KeyBinding[],
|
||||
...customSearchKeymap,
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
|
301
frontend/src/views/editor/extensions/colorSelector/index.ts
Normal file
301
frontend/src/views/editor/extensions/colorSelector/index.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { ViewPlugin, EditorView, ViewUpdate, WidgetType, Decoration, DecorationSet } from '@codemirror/view';
|
||||
import { Extension, Range } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import colors from 'colors-named';
|
||||
import hexs from 'colors-named-hex';
|
||||
import hslMatcher, { hlsStringToRGB, RGBAColor } from 'hsl-matcher';
|
||||
import { toFullHex, rgbToHex, hexToRgb, RGBToHSL } from './utils';
|
||||
|
||||
export enum ColorType {
|
||||
rgb = 'RGB',
|
||||
hex = 'HEX',
|
||||
named = 'NAMED',
|
||||
hsl = 'HSL',
|
||||
}
|
||||
|
||||
export interface ColorState {
|
||||
from: number;
|
||||
to: number;
|
||||
alpha: string;
|
||||
colorType: ColorType;
|
||||
}
|
||||
|
||||
const colorState = new WeakMap<HTMLInputElement, ColorState>();
|
||||
|
||||
type GetArrayElementType<T extends readonly any[]> = T extends readonly (infer U)[] ? U : never;
|
||||
|
||||
function colorDecorations(view: EditorView) {
|
||||
const widgets: Array<Range<Decoration>> = [];
|
||||
for (const range of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
enter: ({ type, from, to }) => {
|
||||
const callExp: string = view.state.doc.sliceString(from, to);
|
||||
/**
|
||||
* ```
|
||||
* rgb(0 107 128, .5); ❌ ❌ ❌
|
||||
* rgb( 0 107 128 ); ✅ ✅ ✅
|
||||
* RGB( 0 107 128 ); ✅ ✅ ✅
|
||||
* Rgb( 0 107 128 ); ✅ ✅ ✅
|
||||
* rgb( 0 107 128 / ); ❌ ❌ ❌
|
||||
* rgb( 0 107 128 / 60%); ✅ ✅ ✅
|
||||
* rgb(0,107,128 / 60%); ❌ ❌ ❌
|
||||
* rgb( 255, 255, 255 ) ✅ ✅ ✅
|
||||
* rgba( 255, 255, 255 ) ✅ ✅ ✅
|
||||
* rgba( 255, 255 , 255, ) ❌ ❌ ❌
|
||||
* rgba( 255, 255 , 255, .5 ) ✅ ✅ ✅
|
||||
* rgba( 255 255 255 / 0.5 ); ✅ ✅ ✅
|
||||
* rgba( 255 255 255 0.5 ); ❌ ❌ ❌
|
||||
* rgba( 255 255 255 / ); ❌ ❌ ❌
|
||||
* ```
|
||||
*/
|
||||
if (type.name === 'CallExpression' && callExp.startsWith('rgb')) {
|
||||
const match =
|
||||
/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,?\s*(\d{1,3})\s*(,\s*\d*\.\d*\s*)?\)/i.exec(callExp) ||
|
||||
/rgba?\(\s*(\d{1,3})\s*(\d{1,3})\s*(\d{1,3})\s*(\/?\s*\d+%)?(\/\s*\d+\.\d\s*)?\)/i.exec(callExp);
|
||||
if (!match) return;
|
||||
const [_, r, g, b, a] = match;
|
||||
const hex = rgbToHex(Number(r), Number(g), Number(b));
|
||||
const widget = Decoration.widget({
|
||||
widget: new ColorWidget({
|
||||
colorType: ColorType.rgb,
|
||||
color: hex,
|
||||
colorRaw: callExp,
|
||||
from,
|
||||
to,
|
||||
alpha: a ? a.replace(/(\/|,)/g, '') : '',
|
||||
}),
|
||||
side: 0,
|
||||
});
|
||||
widgets.push(widget.range(from));
|
||||
} else if (type.name === 'CallExpression' && hslMatcher(callExp)) {
|
||||
/**
|
||||
* # valid
|
||||
* hsl(240, 100%, 50%) // ✅ comma separated
|
||||
* hsl(240, 100%, 50%, 0.1) // ✅ comma separated with opacity
|
||||
* hsl(240, 100%, 50%, 10%) // ✅ comma separated with % opacity
|
||||
* hsl(240,100%,50%,0.1) // ✅ comma separated without spaces
|
||||
* hsl(180deg, 100%, 50%, 0.1) // ✅ hue with 'deg'
|
||||
* hsl(3.14rad, 100%, 50%, 0.1) // ✅ hue with 'rad'
|
||||
* hsl(200grad, 100%, 50%, 0.1) // ✅ hue with 'grad'
|
||||
* hsl(0.5turn, 100%, 50%, 0.1) // ✅ hue with 'turn'
|
||||
* hsl(-240, -100%, -50%, -0.1) // ✅ negative values
|
||||
* hsl(+240, +100%, +50%, +0.1) // ✅ explicit positive sign
|
||||
* hsl(240.5, 99.99%, 49.999%, 0.9999) // ✅ non-integer values
|
||||
* hsl(.9, .99%, .999%, .9999) // ✅ fraction w/o leading zero
|
||||
* hsl(0240, 0100%, 0050%, 01) // ✅ leading zeros
|
||||
* hsl(240.0, 100.00%, 50.000%, 1.0000) // ✅ trailing decimal zeros
|
||||
* hsl(2400, 1000%, 1000%, 10) // ✅ out of range values
|
||||
* hsl(-2400.01deg, -1000.5%, -1000.05%, -100) // ✅ combination of above
|
||||
* hsl(2.40e+2, 1.00e+2%, 5.00e+1%, 1E-3) // ✅ scientific notation
|
||||
* hsl(240 100% 50%) // ✅ space separated (CSS Color Level 4)
|
||||
* hsl(240 100% 50% / 0.1) // ✅ space separated with opacity
|
||||
* hsla(240, 100%, 50%) // ✅ hsla() alias
|
||||
* hsla(240, 100%, 50%, 0.1) // ✅ hsla() with opacity
|
||||
* HSL(240Deg, 100%, 50%) // ✅ case insensitive
|
||||
*/
|
||||
const match = hlsStringToRGB(callExp) as RGBAColor;
|
||||
if (!match) return;
|
||||
const { r, g, b } = match;
|
||||
const hex = rgbToHex(Number(r), Number(g), Number(b));
|
||||
const widget = Decoration.widget({
|
||||
widget: new ColorWidget({
|
||||
colorType: ColorType.hsl,
|
||||
color: hex,
|
||||
colorRaw: callExp,
|
||||
from,
|
||||
to,
|
||||
alpha: match.a ? match.a.toString() : '',
|
||||
}),
|
||||
side: 0,
|
||||
});
|
||||
widgets.push(widget.range(from));
|
||||
} else if (type.name === 'ColorLiteral') {
|
||||
const [color, alpha] = toFullHex(callExp);
|
||||
const widget = Decoration.widget({
|
||||
widget: new ColorWidget({
|
||||
colorType: ColorType.hex,
|
||||
color,
|
||||
colorRaw: callExp,
|
||||
from,
|
||||
to,
|
||||
alpha,
|
||||
}),
|
||||
side: 0,
|
||||
});
|
||||
widgets.push(widget.range(from));
|
||||
} else if (type.name === 'ValueName') {
|
||||
const name = callExp as unknown as GetArrayElementType<typeof colors>;
|
||||
if (colors.includes(name)) {
|
||||
const widget = Decoration.widget({
|
||||
widget: new ColorWidget({
|
||||
colorType: ColorType.named,
|
||||
color: hexs[colors.indexOf(name)],
|
||||
colorRaw: callExp,
|
||||
from,
|
||||
to,
|
||||
alpha: '',
|
||||
}),
|
||||
side: 0,
|
||||
});
|
||||
widgets.push(widget.range(from));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
class ColorWidget extends WidgetType {
|
||||
private readonly state: ColorState;
|
||||
private readonly color: string;
|
||||
private readonly colorRaw: string;
|
||||
|
||||
constructor({
|
||||
color,
|
||||
colorRaw,
|
||||
...state
|
||||
}: ColorState & {
|
||||
color: string;
|
||||
colorRaw: string;
|
||||
}) {
|
||||
super();
|
||||
this.state = state;
|
||||
this.color = color;
|
||||
this.colorRaw = colorRaw;
|
||||
}
|
||||
eq(other: ColorWidget) {
|
||||
return (
|
||||
other.state.colorType === this.state.colorType &&
|
||||
other.color === this.color &&
|
||||
other.state.from === this.state.from &&
|
||||
other.state.to === this.state.to &&
|
||||
other.state.alpha === this.state.alpha
|
||||
);
|
||||
}
|
||||
toDOM() {
|
||||
const picker = document.createElement('input');
|
||||
colorState.set(picker, this.state);
|
||||
picker.type = 'color';
|
||||
picker.value = this.color;
|
||||
picker.dataset['color'] = this.color;
|
||||
picker.dataset['colorraw'] = this.colorRaw;
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.appendChild(picker);
|
||||
wrapper.dataset['color'] = this.color;
|
||||
wrapper.style.backgroundColor = this.colorRaw;
|
||||
return wrapper;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const colorView = (showPicker: boolean = true) =>
|
||||
ViewPlugin.fromClass(
|
||||
class ColorView {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = colorDecorations(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = colorDecorations(update.view);
|
||||
}
|
||||
const readOnly = update.view.contentDOM.ariaReadOnly === 'true';
|
||||
const editable = update.view.contentDOM.contentEditable === 'true';
|
||||
|
||||
const canBeEdited = readOnly === false && editable;
|
||||
this.changePicker(update.view, canBeEdited);
|
||||
}
|
||||
changePicker(view: EditorView, canBeEdited: boolean) {
|
||||
const doms = view.contentDOM.querySelectorAll('input[type=color]');
|
||||
doms.forEach((inp) => {
|
||||
if (!showPicker) {
|
||||
inp.setAttribute('disabled', '');
|
||||
} else {
|
||||
canBeEdited ? inp.removeAttribute('disabled') : inp.setAttribute('disabled', '');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
eventHandlers: {
|
||||
change: (e, view) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (
|
||||
target.nodeName !== 'INPUT' ||
|
||||
!target.parentElement ||
|
||||
(!target.dataset.color && !target.dataset.colorraw)
|
||||
)
|
||||
return false;
|
||||
const data = colorState.get(target)!;
|
||||
const value = target.value;
|
||||
const rgb = hexToRgb(value);
|
||||
const colorraw = target.dataset.colorraw;
|
||||
const slash = (target.dataset.colorraw || '').indexOf('/') > 4;
|
||||
const comma = (target.dataset.colorraw || '').indexOf(',') > 4;
|
||||
let converted = target.value;
|
||||
if (data.colorType === ColorType.rgb) {
|
||||
let funName = colorraw?.match(/^(rgba?)/) ? colorraw?.match(/^(rgba?)/)![0] : undefined;
|
||||
if (comma) {
|
||||
converted = rgb
|
||||
? `${funName}(${rgb.r}, ${rgb.g}, ${rgb.b}${data.alpha ? ', ' + data.alpha.trim() : ''})`
|
||||
: value;
|
||||
} else if (slash) {
|
||||
converted = rgb
|
||||
? `${funName}(${rgb.r} ${rgb.g} ${rgb.b}${data.alpha ? ' / ' + data.alpha.trim() : ''})`
|
||||
: value;
|
||||
} else {
|
||||
converted = rgb ? `${funName}(${rgb.r} ${rgb.g} ${rgb.b})` : value;
|
||||
}
|
||||
} else if (data.colorType === ColorType.hsl) {
|
||||
const rgb = hexToRgb(value);
|
||||
if (rgb) {
|
||||
const { h, s, l } = RGBToHSL(rgb?.r, rgb?.g, rgb?.b);
|
||||
converted = `hsl(${h}deg ${s}% ${l}%${data.alpha ? ' / ' + data.alpha : ''})`;
|
||||
}
|
||||
}
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: data.from,
|
||||
to: data.to,
|
||||
insert: converted,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const colorTheme = EditorView.baseTheme({
|
||||
'span[data-color]': {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
display: 'inline-block',
|
||||
borderRadius: '2px',
|
||||
marginRight: '0.5ch',
|
||||
outline: '1px solid #00000040',
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'middle',
|
||||
marginTop: '-2px',
|
||||
},
|
||||
'span[data-color] input[type="color"]': {
|
||||
background: 'transparent',
|
||||
display: 'block',
|
||||
border: 'none',
|
||||
outline: '0',
|
||||
paddingLeft: '24px',
|
||||
height: '12px',
|
||||
},
|
||||
'span[data-color] input[type="color"]::-webkit-color-swatch': {
|
||||
border: 'none',
|
||||
paddingLeft: '24px',
|
||||
},
|
||||
});
|
||||
|
||||
export const color: Extension = [colorView(), colorTheme];
|
64
frontend/src/views/editor/extensions/colorSelector/utils.ts
Normal file
64
frontend/src/views/editor/extensions/colorSelector/utils.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export function toFullHex(color: string): string[] {
|
||||
if (color.length === 4) {
|
||||
// 3-char hex
|
||||
return [`#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, ''];
|
||||
}
|
||||
|
||||
if (color.length === 5) {
|
||||
// 4-char hex (alpha)
|
||||
return [`#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`, color[4].repeat(2)];
|
||||
}
|
||||
|
||||
if (color.length === 9) {
|
||||
// 8-char hex (alpha)
|
||||
return [`#${color.slice(1, -2)}`, color.slice(-2)];
|
||||
}
|
||||
|
||||
return [color, ''];
|
||||
}
|
||||
/** https://stackoverflow.com/a/5624139/1334703 */
|
||||
export function rgbToHex(r: number, g: number, b: number) {
|
||||
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
}
|
||||
|
||||
/** https://stackoverflow.com/a/5624139/1334703 */
|
||||
export function hexToRgb(hex: string) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** https://css-tricks.com/converting-color-spaces-in-javascript/#aa-rgb-to-hsl */
|
||||
export function RGBToHSL(r: number, g: number, b: number) {
|
||||
(r /= 255), (g /= 255), (b /= 255);
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
let h = 0,
|
||||
s,
|
||||
l = (max + min) / 2;
|
||||
|
||||
if (max == min) {
|
||||
h = s = 0; // achromatic
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h: Math.floor(h * 360), s: Math.floor(s * 100), l: Math.floor(l * 100) };
|
||||
}
|
@@ -127,8 +127,8 @@ export class CustomSearchPanel {
|
||||
|
||||
if (query.regexp) {
|
||||
try {
|
||||
this.regexCursor = new RegExpCursor(state.doc, query.search)
|
||||
this.searchCursor = undefined;
|
||||
this.regexCursor = new RegExpCursor(state.doc, query.search)
|
||||
this.searchCursor = undefined;
|
||||
} catch (error) {
|
||||
// 如果正则表达式无效,清空匹配结果并显示错误状态
|
||||
console.warn("Invalid regular expression:", query.search, error);
|
||||
@@ -168,17 +168,17 @@ export class CustomSearchPanel {
|
||||
}
|
||||
else if (this.regexCursor) {
|
||||
try {
|
||||
const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head))
|
||||
const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head))
|
||||
|
||||
while (!this.regexCursor.done) {
|
||||
this.regexCursor.next();
|
||||
while (!this.regexCursor.done) {
|
||||
this.regexCursor.next();
|
||||
|
||||
if (!this.regexCursor.done) {
|
||||
const { from, to, match } = this.regexCursor.value;
|
||||
if (!this.regexCursor.done) {
|
||||
const { from, to, match } = this.regexCursor.value;
|
||||
|
||||
if (!query.wholeWord || matchWord(from, to, match)) {
|
||||
this.matches.push({ from, to });
|
||||
}
|
||||
if (!query.wholeWord || matchWord(from, to, match)) {
|
||||
this.matches.push({ from, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -222,19 +222,19 @@ export class CustomSearchPanel {
|
||||
|
||||
commit() {
|
||||
try {
|
||||
const newQuery = new SearchQuery({
|
||||
search: this.searchField.value,
|
||||
replace: this.replaceField.value,
|
||||
caseSensitive: this.matchCase,
|
||||
regexp: this.useRegex,
|
||||
wholeWord: this.matchWord,
|
||||
})
|
||||
const newQuery = new SearchQuery({
|
||||
search: this.searchField.value,
|
||||
replace: this.replaceField.value,
|
||||
caseSensitive: this.matchCase,
|
||||
regexp: this.useRegex,
|
||||
wholeWord: this.matchWord,
|
||||
})
|
||||
|
||||
let query = getSearchQuery(this.view.state)
|
||||
if (!newQuery.eq(query)) {
|
||||
this.view.dispatch({
|
||||
effects: setSearchQuery.of(newQuery)
|
||||
})
|
||||
let query = getSearchQuery(this.view.state)
|
||||
if (!newQuery.eq(query)) {
|
||||
this.view.dispatch({
|
||||
effects: setSearchQuery.of(newQuery)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果创建SearchQuery时出错(通常是无效的正则表达式),记录错误但不中断程序
|
||||
@@ -367,7 +367,7 @@ export class CustomSearchPanel {
|
||||
});
|
||||
|
||||
// 重新查找匹配项
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,7 +392,7 @@ export class CustomSearchPanel {
|
||||
});
|
||||
|
||||
// 重新查找匹配项
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
this.findMatchesAndSelectClosest(this.view.state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ export class CustomSearchPanel {
|
||||
if (visible) {
|
||||
// 使用 setTimeout 确保DOM已经渲染
|
||||
setTimeout(() => {
|
||||
this.searchField.focus();
|
||||
this.searchField.focus();
|
||||
this.searchField.select();
|
||||
}, 0);
|
||||
}
|
@@ -72,10 +72,10 @@ export const customSearchKeymap: KeyBinding[] = [
|
||||
key: "ArrowLeft",
|
||||
run: searchMoveCursorLeft,
|
||||
scope: 'search'
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
key: "ArrowRight",
|
||||
run: searchMoveCursorRight,
|
||||
scope: 'search'
|
||||
},
|
||||
},
|
||||
];
|
Reference in New Issue
Block a user