🎨 Update

This commit is contained in:
2025-06-12 00:58:35 +08:00
parent 4844ccdf58
commit 0927b921c3
15 changed files with 559 additions and 76 deletions

View File

@@ -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);

View File

@@ -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,

View 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];

View 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) };
}

View File

@@ -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);
}

View File

@@ -72,10 +72,10 @@ export const customSearchKeymap: KeyBinding[] = [
key: "ArrowLeft",
run: searchMoveCursorLeft,
scope: 'search'
},
{
},
{
key: "ArrowRight",
run: searchMoveCursorRight,
scope: 'search'
},
},
];