mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 00:23:37 +08:00
feat: words editor replacement highlight (#644)
This commit is contained in:
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"src/ace-config.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 31
|
||||
"count": 24
|
||||
},
|
||||
"no-useless-escape": {
|
||||
"count": 4
|
||||
|
||||
@@ -7,17 +7,22 @@ interface AceToken {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface WordListSyntaxMode {
|
||||
interface WordListMode {
|
||||
getTokenizer: () => {
|
||||
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||
}
|
||||
}
|
||||
|
||||
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
|
||||
const tokenizer = new WordListSyntaxMode().getTokenizer()
|
||||
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
|
||||
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
|
||||
const plainTokenizer = new WordListMode().getTokenizer()
|
||||
|
||||
function tokenize(line: string) {
|
||||
return tokenizer.getLineTokens(line, 'start').tokens
|
||||
return syntaxTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
function tokenizePlain(line: string) {
|
||||
return plainTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
describe('word list syntax mode', () => {
|
||||
@@ -25,14 +30,103 @@ describe('word list syntax mode', () => {
|
||||
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
|
||||
})
|
||||
|
||||
it('separates replaced and replacement fields without parsing their content', () => {
|
||||
it('parses valid replacement parameters', () => {
|
||||
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名.*' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
|
||||
{ type: 'word_list_replacement', value: '新名 ' },
|
||||
{ type: 'word_list_parameter_syntax', value: '{[' },
|
||||
{ type: 'word_list_parameter_key', value: 'tmdbid' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '123' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'type' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: 'tv' },
|
||||
{ type: 'word_list_parameter_syntax', value: ']}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks invalid replacement parameter keys and values', () => {
|
||||
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 ' },
|
||||
{ type: 'word_list_parameter_syntax', value: '{[' },
|
||||
{ type: 'invalid.word-list', value: 'unknown' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '1' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'tmdbid' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'invalid.word-list', value: 'abc' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'type' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'invalid.word-list', value: 'anime' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'g' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: 'group' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 's' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '2' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'e' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '3' },
|
||||
{ type: 'word_list_parameter_syntax', value: ']}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks an unknown replacement parameter key as invalid', () => {
|
||||
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
|
||||
})
|
||||
|
||||
it('marks visible syntax for missing or malformed replacement parameters', () => {
|
||||
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
|
||||
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
|
||||
})
|
||||
|
||||
it('marks the second semicolon when it creates an empty parameter', () => {
|
||||
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
|
||||
|
||||
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
|
||||
})
|
||||
|
||||
it('marks both sides of missing keys and values', () => {
|
||||
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
|
||||
})
|
||||
|
||||
it('treats an unclosed parameter block as ordinary replacement text', () => {
|
||||
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts every supported replacement parameter type', () => {
|
||||
expect(
|
||||
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
|
||||
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
|
||||
})
|
||||
|
||||
it('accepts season and episode number ranges', () => {
|
||||
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'invalid.word-list' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('separates front, back, and episode offset fields', () => {
|
||||
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
|
||||
{ type: 'word_list_front', value: '第' },
|
||||
@@ -59,4 +153,24 @@ describe('word list syntax mode', () => {
|
||||
])
|
||||
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
|
||||
})
|
||||
|
||||
it('highlights comment lines', () => {
|
||||
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('word list plain mode', () => {
|
||||
it('does not highlight comment lines', () => {
|
||||
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
|
||||
})
|
||||
|
||||
it('does not highlight word list fields', () => {
|
||||
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
|
||||
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no tokens for empty lines', () => {
|
||||
expect(tokenizePlain('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -530,6 +530,234 @@ function registerJinja2Mode() {
|
||||
)
|
||||
}
|
||||
|
||||
interface WordListToken {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
|
||||
const wordListUnsignedIntegerPattern = /^\d+$/
|
||||
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
|
||||
const wordListParameterTypes = {
|
||||
tmdbid: 'uint',
|
||||
doubanid: 'uint',
|
||||
bangumiid: 'uint',
|
||||
anilistid: 'uint',
|
||||
type: 'media-type',
|
||||
g: 'string',
|
||||
s: 'uint-or-range',
|
||||
e: 'uint-or-range',
|
||||
} as const
|
||||
|
||||
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
|
||||
if (!value) return
|
||||
|
||||
const previousToken = tokens.at(-1)
|
||||
if (previousToken?.type === type) previousToken.value += value
|
||||
else tokens.push({ type, value })
|
||||
}
|
||||
|
||||
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
|
||||
switch (wordListParameterTypes[key]) {
|
||||
case 'uint':
|
||||
return wordListUnsignedIntegerPattern.test(value)
|
||||
case 'uint-or-range':
|
||||
return wordListUnsignedIntegerOrRangePattern.test(value)
|
||||
case 'media-type':
|
||||
return value === 'movie' || value === 'tv'
|
||||
case 'string':
|
||||
return value.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeWordListParameters(parameters: string): WordListToken[] {
|
||||
const tokens: WordListToken[] = []
|
||||
const parameterList = parameters.split(';')
|
||||
|
||||
parameterList.forEach((parameter, index) => {
|
||||
const isEmptyParameter = parameter.length === 0
|
||||
if (index > 0) {
|
||||
appendWordListToken(
|
||||
tokens,
|
||||
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
|
||||
';',
|
||||
)
|
||||
}
|
||||
if (isEmptyParameter) return
|
||||
|
||||
const equalsIndex = parameter.indexOf('=')
|
||||
if (equalsIndex === -1) {
|
||||
appendWordListToken(tokens, 'invalid.word-list', parameter)
|
||||
return
|
||||
}
|
||||
|
||||
const key = parameter.slice(0, equalsIndex)
|
||||
const value = parameter.slice(equalsIndex + 1)
|
||||
const hasKey = key.length > 0
|
||||
const hasValue = value.length > 0
|
||||
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
|
||||
|
||||
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
|
||||
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
|
||||
|
||||
const isValidValue =
|
||||
isKnownKey &&
|
||||
!value.includes('=') &&
|
||||
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
|
||||
appendWordListToken(
|
||||
tokens,
|
||||
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
|
||||
value,
|
||||
)
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
|
||||
const tokens: WordListToken[] = []
|
||||
let replacementStart = 0
|
||||
|
||||
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
|
||||
const parameterStart = match.index ?? 0
|
||||
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
|
||||
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
|
||||
else {
|
||||
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
|
||||
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
|
||||
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
|
||||
}
|
||||
replacementStart = parameterStart + match[0].length
|
||||
}
|
||||
|
||||
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
|
||||
return tokens
|
||||
}
|
||||
|
||||
function splitWordListField(value: string, operator: string) {
|
||||
let operatorIndex = value.indexOf(operator)
|
||||
|
||||
while (operatorIndex !== -1) {
|
||||
const operatorEnd = operatorIndex + operator.length
|
||||
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
|
||||
let operatorStart = operatorIndex
|
||||
while (value[operatorStart - 1] === ' ') operatorStart -= 1
|
||||
|
||||
let operatorWithSpacesEnd = operatorEnd
|
||||
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
|
||||
|
||||
return {
|
||||
before: value.slice(0, operatorStart),
|
||||
operator: value.slice(operatorStart, operatorWithSpacesEnd),
|
||||
after: value.slice(operatorWithSpacesEnd),
|
||||
}
|
||||
}
|
||||
|
||||
operatorIndex = value.indexOf(operator, operatorEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function getWordListLineWhitespace(value: string) {
|
||||
const leadingSpaceLength = value.length - value.trimStart().length
|
||||
const trailingSpaceLength = value.length - value.trimEnd().length
|
||||
|
||||
return {
|
||||
leadingSpace: value.slice(0, leadingSpaceLength),
|
||||
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
|
||||
trailingSpace: value.slice(value.length - trailingSpaceLength),
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
|
||||
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
|
||||
const replacementField = splitWordListField(content, '=>')
|
||||
if (!replacementField) return [{ type: 'text', value }]
|
||||
|
||||
const tokens: WordListToken[] = []
|
||||
appendWordListToken(tokens, 'text', leadingSpace)
|
||||
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
|
||||
tokenizeWordListReplacement(replacementField.after).forEach(token =>
|
||||
appendWordListToken(tokens, token.type, token.value),
|
||||
)
|
||||
appendWordListToken(tokens, 'text', trailingSpace)
|
||||
return tokens
|
||||
}
|
||||
|
||||
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
|
||||
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
|
||||
const replacementField = splitWordListField(content, '=>')
|
||||
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
|
||||
const backField = frontField && splitWordListField(frontField.after, '<>')
|
||||
const offsetField = backField && splitWordListField(backField.after, '>>')
|
||||
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
|
||||
|
||||
const tokens: WordListToken[] = []
|
||||
appendWordListToken(tokens, 'text', leadingSpace)
|
||||
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
|
||||
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
|
||||
appendWordListToken(tokens, 'word_list_front', backField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
|
||||
appendWordListToken(tokens, 'word_list_back', offsetField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
|
||||
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
|
||||
appendWordListToken(tokens, 'text', trailingSpace)
|
||||
return tokens
|
||||
}
|
||||
|
||||
function buildWordListRules(syntax: boolean) {
|
||||
if (!syntax) {
|
||||
return {
|
||||
start: [
|
||||
{
|
||||
token: 'empty_line',
|
||||
regex: '^$',
|
||||
},
|
||||
{
|
||||
defaultToken: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
onMatch: tokenizeCombinedWordListLine,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||
onMatch: tokenizeWordListReplacementLine,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_block', 'text'],
|
||||
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function registerWordListMode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_highlight_rules',
|
||||
@@ -538,15 +766,8 @@ function registerWordListMode() {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const WordListHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
],
|
||||
}
|
||||
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
|
||||
this.$rules = buildWordListRules(options?.syntax === true)
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
@@ -564,9 +785,10 @@ function registerWordListMode() {
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
const Mode = function (this: any, options?: { syntax?: boolean }) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListHighlightRules
|
||||
this.$highlightRuleConfig = options || {}
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
@@ -578,90 +800,6 @@ function registerWordListMode() {
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_syntax_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const WordListSyntaxHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_replaced',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_replacement',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_block', 'text'],
|
||||
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
|
||||
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_syntax',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListSyntaxHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/word_list_syntax'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { configureAceEditorPadding } from '@/utils/aceEditor'
|
||||
import type { Ace } from 'ace-builds'
|
||||
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
|
||||
@@ -52,7 +53,27 @@ const saving = ref(false)
|
||||
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
|
||||
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
|
||||
|
||||
const textEditorLanguage = computed(() => (showSyntaxHighlighting.value ? 'word_list_syntax' : 'word_list'))
|
||||
interface WordListModeConfig {
|
||||
path: 'ace/mode/word_list'
|
||||
syntax: boolean
|
||||
}
|
||||
|
||||
const aceEditor = shallowRef<Ace.Editor | null>(null)
|
||||
|
||||
function onAceInit(editor: Ace.Editor) {
|
||||
aceEditor.value = editor
|
||||
configureAceEditorPadding(editor)
|
||||
applyWordListSyntax()
|
||||
}
|
||||
|
||||
function applyWordListSyntax() {
|
||||
if (!aceEditor.value) return
|
||||
const mode: WordListModeConfig = {
|
||||
path: 'ace/mode/word_list',
|
||||
syntax: showSyntaxHighlighting.value,
|
||||
}
|
||||
aceEditor.value.session.setMode(mode as unknown as Ace.SyntaxMode)
|
||||
}
|
||||
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
||||
const textEditorOptions = computed(() => ({
|
||||
fontSize: 13.6,
|
||||
@@ -73,6 +94,8 @@ watch(showSyntaxHighlighting, value => {
|
||||
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
|
||||
})
|
||||
|
||||
watch(showSyntaxHighlighting, applyWordListSyntax)
|
||||
|
||||
const savedTextValues = reactive<Record<TextSectionKey, string>>({
|
||||
identifiers: '',
|
||||
releaseGroups: '',
|
||||
@@ -528,14 +551,14 @@ onMounted(() => {
|
||||
<VAceEditor
|
||||
v-if="activeSection === 'identifiers'"
|
||||
v-model:value="activeTextValue"
|
||||
:lang="textEditorLanguage"
|
||||
lang="word_list"
|
||||
:theme="textEditorTheme"
|
||||
:options="textEditorOptions"
|
||||
:placeholder="activeTextPlaceholder"
|
||||
:print-margin="false"
|
||||
wrap
|
||||
class="words-text-editor"
|
||||
@init="configureAceEditorPadding"
|
||||
@init="onAceInit"
|
||||
/>
|
||||
<VTextarea
|
||||
v-else
|
||||
@@ -924,6 +947,9 @@ onMounted(() => {
|
||||
--words-token-block: #af00db;
|
||||
--words-token-replaced: #001080;
|
||||
--words-token-replacement: #a31515;
|
||||
--words-token-parameter-syntax: #795e26;
|
||||
--words-token-parameter-key: #0451a5;
|
||||
--words-token-parameter-value: #098658;
|
||||
--words-token-front: #267f99;
|
||||
--words-token-back: #795e26;
|
||||
--words-token-offset: #098658;
|
||||
@@ -943,6 +969,9 @@ onMounted(() => {
|
||||
--words-token-block: #c586c0;
|
||||
--words-token-replaced: #9cdcfe;
|
||||
--words-token-replacement: #ce9178;
|
||||
--words-token-parameter-syntax: #dcdcaa;
|
||||
--words-token-parameter-key: #9cdcfe;
|
||||
--words-token-parameter-value: #b5cea8;
|
||||
--words-token-front: #4ec9b0;
|
||||
--words-token-back: #dcdcaa;
|
||||
--words-token-offset: #b5cea8;
|
||||
@@ -982,6 +1011,25 @@ onMounted(() => {
|
||||
color: var(--words-token-replacement);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_parameter_syntax) {
|
||||
color: var(--words-token-parameter-syntax);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_parameter_key) {
|
||||
color: var(--words-token-parameter-key);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_parameter_value) {
|
||||
color: var(--words-token-parameter-value);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_invalid.ace_word-list) {
|
||||
color: #f44336 !important;
|
||||
background-color: transparent !important;
|
||||
text-decoration: underline wavy #f44336 !important;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_front) {
|
||||
color: var(--words-token-front);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
|
||||
apiPost: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
aceSetMode: vi.fn(),
|
||||
aceSetPadding: vi.fn(),
|
||||
aceSetScrollMargin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
@@ -33,6 +36,13 @@ const AceEditorStub = defineComponent({
|
||||
options: { type: Object, default: () => ({}) },
|
||||
value: { type: String, default: '' },
|
||||
},
|
||||
emits: ['init', 'update:value'],
|
||||
mounted() {
|
||||
this.$emit('init', {
|
||||
session: { setMode: mocks.aceSetMode },
|
||||
renderer: { setPadding: mocks.aceSetPadding, setScrollMargin: mocks.aceSetScrollMargin },
|
||||
})
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
data-testid="words-ace-editor"
|
||||
@@ -61,6 +71,9 @@ describe('WordsView editor preferences', () => {
|
||||
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
|
||||
})
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
mocks.aceSetMode.mockClear()
|
||||
mocks.aceSetPadding.mockClear()
|
||||
mocks.aceSetScrollMargin.mockClear()
|
||||
})
|
||||
|
||||
it('keeps line numbers disabled when no preference is stored', async () => {
|
||||
@@ -74,6 +87,7 @@ describe('WordsView editor preferences', () => {
|
||||
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
|
||||
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: false })
|
||||
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
|
||||
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
|
||||
})
|
||||
@@ -110,7 +124,8 @@ describe('WordsView editor preferences', () => {
|
||||
const editor = await screen.findByTestId('words-ace-editor')
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
|
||||
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: true })
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||
})
|
||||
|
||||
it('updates Ace options and persists the preference without changing content', async () => {
|
||||
@@ -139,7 +154,7 @@ describe('WordsView editor preferences', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
|
||||
it('switches word list syntax highlighting and persists the preference without changing content', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWordsView()
|
||||
|
||||
@@ -149,7 +164,7 @@ describe('WordsView editor preferences', () => {
|
||||
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
|
||||
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: true })
|
||||
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
|
||||
})
|
||||
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
|
||||
@@ -158,7 +173,7 @@ describe('WordsView editor preferences', () => {
|
||||
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: false })
|
||||
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user