feat: 为词表编辑器增加可选行号与语法高亮 (#546)

This commit is contained in:
秋澪Akimio
2026-07-17 23:34:49 +08:00
committed by GitHub
parent 435e9ecfdd
commit 9a838647fe
7 changed files with 458 additions and 6 deletions
+62
View File
@@ -0,0 +1,62 @@
import ace from 'ace-builds'
import { describe, expect, it } from 'vitest'
import '@/ace-config'
interface AceToken {
type: string
value: string
}
interface WordListSyntaxMode {
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()
function tokenize(line: string) {
return tokenizer.getLineTokens(line, 'start').tokens
}
describe('word list syntax mode', () => {
it('highlights a block word as one field', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('separates replaced and replacement fields without parsing their content', () => {
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]}' },
])
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: 'EP+1' },
])
})
it('uses all six field types in combined and standalone rules', () => {
const combinedTokens = tokenize('旧名 => 新名 && 第 <> 集 >> 2*EP-1')
expect(combinedTokens).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名' },
{ type: 'keyword.operator.word-list', value: ' && ' },
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: '2*EP-1' },
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
})
+84
View File
@@ -578,6 +578,90 @@ function registerWordListMode() {
exports.Mode = Mode 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) ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
+2
View File
@@ -2215,6 +2215,8 @@ export default {
entryCount: '{count} entries', entryCount: '{count} entries',
ruleCount: '{count} rules', ruleCount: '{count} rules',
listLabel: 'Word list content (one rule per line)', listLabel: 'Word list content (one rule per line)',
lineNumbers: 'Line numbers',
syntaxHighlighting: 'Syntax highlighting',
saved: 'Saved', saved: 'Saved',
unsaved: 'Unsaved changes', unsaved: 'Unsaved changes',
saveChanges: 'Save changes', saveChanges: 'Save changes',
+2
View File
@@ -2177,6 +2177,8 @@ export default {
entryCount: '{count} 条', entryCount: '{count} 条',
ruleCount: '共 {count} 条规则', ruleCount: '共 {count} 条规则',
listLabel: '词表内容(每行一个规则)', listLabel: '词表内容(每行一个规则)',
lineNumbers: '行号',
syntaxHighlighting: '语法高亮',
saved: '已保存', saved: '已保存',
unsaved: '有未保存修改', unsaved: '有未保存修改',
saveChanges: '保存更改', saveChanges: '保存更改',
+2
View File
@@ -2176,6 +2176,8 @@ export default {
entryCount: '{count} 條', entryCount: '{count} 條',
ruleCount: '共 {count} 條規則', ruleCount: '共 {count} 條規則',
listLabel: '詞表內容(每行一個規則)', listLabel: '詞表內容(每行一個規則)',
lineNumbers: '行號',
syntaxHighlighting: '語法高亮',
saved: '已保存', saved: '已保存',
unsaved: '有未保存修改', unsaved: '有未保存修改',
saveChanges: '保存更改', saveChanges: '保存更改',
+127 -6
View File
@@ -10,6 +10,9 @@ const { t } = useI18n()
const $toast = useToast() const $toast = useToast()
const { global: globalTheme } = useTheme() const { global: globalTheme } = useTheme()
const WORDS_LINE_NUMBERS_STORAGE_KEY = 'MP_WORDS_SHOW_LINE_NUMBERS'
const WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY = 'MP_WORDS_SYNTAX_HIGHLIGHTING'
type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords' type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords'
type WordSectionKey = TextSectionKey | 'episodeRules' type WordSectionKey = TextSectionKey | 'episodeRules'
@@ -45,16 +48,29 @@ const episodeFormatRules = ref<EpisodeFormatRule[]>([])
const activeSection = ref<WordSectionKey>('identifiers') const activeSection = ref<WordSectionKey>('identifiers')
const expandedHelp = ref<string | null>(null) const expandedHelp = ref<string | null>(null)
const saving = ref(false) 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'))
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default')) const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
const textEditorOptions = { const textEditorOptions = computed(() => ({
fontSize: 13.6, fontSize: 13.6,
highlightActiveLine: false, highlightActiveLine: false,
scrollPastEnd: 0, scrollPastEnd: 0,
showGutter: false, showFoldWidgets: false,
showGutter: showLineNumbers.value,
showLineNumbers: showLineNumbers.value,
showPrintMargin: false, showPrintMargin: false,
tabSize: 2, tabSize: 2,
} }))
watch(showLineNumbers, value => {
localStorage.setItem(WORDS_LINE_NUMBERS_STORAGE_KEY, String(value))
})
watch(showSyntaxHighlighting, value => {
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
})
const savedTextValues = reactive<Record<TextSectionKey, string>>({ const savedTextValues = reactive<Record<TextSectionKey, string>>({
identifiers: '', identifiers: '',
@@ -483,13 +499,33 @@ onMounted(() => {
<template v-if="isTextSection"> <template v-if="isTextSection">
<div class="words-field-meta"> <div class="words-field-meta">
<strong>{{ t('setting.words.listLabel') }}</strong> <strong>{{ t('setting.words.listLabel') }}</strong>
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span> <div class="words-field-actions">
<span>{{ t('setting.words.entryCount', { count: activeSectionCount }) }}</span>
<VSwitch
v-if="activeSection === 'identifiers'"
v-model="showLineNumbers"
class="words-editor-switch"
color="primary"
density="compact"
hide-details
:label="t('setting.words.lineNumbers')"
/>
<VSwitch
v-if="activeSection === 'identifiers'"
v-model="showSyntaxHighlighting"
class="words-editor-switch"
color="primary"
density="compact"
hide-details
:label="t('setting.words.syntaxHighlighting')"
/>
</div>
</div> </div>
<VAceEditor <VAceEditor
v-if="activeSection === 'identifiers'" v-if="activeSection === 'identifiers'"
v-model:value="activeTextValue" v-model:value="activeTextValue"
lang="word_list" :lang="textEditorLanguage"
:theme="textEditorTheme" :theme="textEditorTheme"
:options="textEditorOptions" :options="textEditorOptions"
:placeholder="activeTextPlaceholder" :placeholder="activeTextPlaceholder"
@@ -856,6 +892,23 @@ onMounted(() => {
font-weight: 600; font-weight: 600;
} }
.words-field-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.8rem;
}
.words-editor-switch {
flex: 0 0 auto;
}
.words-editor-switch :deep(.v-label) {
font-size: 0.78rem;
}
.words-textarea :deep(textarea) { .words-textarea :deep(textarea) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem; font-size: 0.85rem;
@@ -867,6 +920,15 @@ onMounted(() => {
} }
.words-text-editor { .words-text-editor {
--words-token-block: #af00db;
--words-token-replaced: #001080;
--words-token-replacement: #a31515;
--words-token-front: #267f99;
--words-token-back: #795e26;
--words-token-offset: #098658;
--words-token-comment: #008000;
--words-token-operator: #000;
overflow: hidden; overflow: hidden;
block-size: 15.8rem; block-size: 15.8rem;
border: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity)); border: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
@@ -876,6 +938,17 @@ onMounted(() => {
transform: translateZ(0); transform: translateZ(0);
} }
.words-text-editor.ace-github-dark {
--words-token-block: #c586c0;
--words-token-replaced: #9cdcfe;
--words-token-replacement: #ce9178;
--words-token-front: #4ec9b0;
--words-token-back: #dcdcaa;
--words-token-offset: #b5cea8;
--words-token-comment: #6a9955;
--words-token-operator: #d4d4d4;
}
.words-text-editor :deep(.ace_scroller), .words-text-editor :deep(.ace_scroller),
.words-text-editor :deep(.ace_content), .words-text-editor :deep(.ace_content),
.words-text-editor :deep(.ace_text-layer) { .words-text-editor :deep(.ace_text-layer) {
@@ -884,10 +957,46 @@ onMounted(() => {
} }
.words-text-editor :deep(.ace_comment) { .words-text-editor :deep(.ace_comment) {
color: rgb(var(--v-theme-success)) !important; color: var(--words-token-comment) !important;
font-style: normal; font-style: normal;
} }
.words-text-editor :deep(.ace_gutter-layer) {
text-align: start;
}
.words-text-editor :deep(.ace_gutter-cell) {
padding-inline: 0.35rem 0.25rem;
}
.words-text-editor :deep(.ace_word_list_block) {
color: var(--words-token-block);
}
.words-text-editor :deep(.ace_word_list_replaced) {
color: var(--words-token-replaced);
}
.words-text-editor :deep(.ace_word_list_replacement) {
color: var(--words-token-replacement);
}
.words-text-editor :deep(.ace_word_list_front) {
color: var(--words-token-front);
}
.words-text-editor :deep(.ace_word_list_back) {
color: var(--words-token-back);
}
.words-text-editor :deep(.ace_word_list_offset) {
color: var(--words-token-offset);
}
.words-text-editor :deep(.ace_keyword.ace_operator.ace_word-list) {
color: var(--words-token-operator);
}
.words-inline-hint { .words-inline-hint {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -1124,9 +1233,21 @@ onMounted(() => {
} }
.words-field-meta { .words-field-meta {
align-items: flex-start;
flex-wrap: wrap;
gap: 0.75rem;
min-block-size: 2.75rem; min-block-size: 2.75rem;
} }
.words-field-meta strong {
min-inline-size: 0;
}
.words-field-actions {
gap: 0.5rem;
inline-size: 100%;
}
.words-textarea :deep(.v-field__input) { .words-textarea :deep(.v-field__input) {
min-block-size: 18rem; min-block-size: 18rem;
} }
@@ -0,0 +1,179 @@
import WordsView from '@/views/system/WordsView.vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
post: mocks.apiPost,
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
const AceEditorStub = defineComponent({
name: 'VAceEditor',
props: {
lang: { type: String, default: 'text' },
options: { type: Object, default: () => ({}) },
value: { type: String, default: '' },
},
template: `
<div
data-testid="words-ace-editor"
:data-lang="lang"
:data-show-gutter="String(Boolean(options.showGutter))"
:data-show-line-numbers="String(Boolean(options.showLineNumbers))"
:data-value="value"
/>
`,
})
async function renderWordsView() {
return renderWithProviders(WordsView, {
global: {
stubs: {
VAceEditor: AceEditorStub,
},
},
})
}
describe('WordsView editor preferences', () => {
beforeEach(() => {
mocks.apiGet.mockImplementation((endpoint: string) => {
if (endpoint.includes('EpisodeFormatRuleTable')) return Promise.resolve({ data: { value: [] } })
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
})
mocks.apiPost.mockResolvedValue({ success: true })
})
it('keeps line numbers disabled when no preference is stored', async () => {
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
const switchControl = screen.getByRole('checkbox', { name: '行号' })
expect(switchControl).not.toBeChecked()
expect(editor).toHaveAttribute('data-show-gutter', 'false')
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
})
it('restores the enabled preference from local storage', async () => {
localStorage.setItem('MP_WORDS_SHOW_LINE_NUMBERS', 'true')
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '行号' })).toBeChecked()
expect(editor).toHaveAttribute('data-show-gutter', 'true')
expect(editor).toHaveAttribute('data-show-line-numbers', 'true')
})
it('treats an unrecognized stored preference as disabled', async () => {
localStorage.setItem('MP_WORDS_SHOW_LINE_NUMBERS', 'enabled')
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '行号' })).not.toBeChecked()
expect(editor).toHaveAttribute('data-show-gutter', 'false')
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
})
it('restores the syntax highlighting preference from local storage', async () => {
localStorage.setItem('MP_WORDS_SYNTAX_HIGHLIGHTING', 'true')
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
})
it('updates Ace options and persists the preference without changing content', async () => {
const user = userEvent.setup()
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
await user.click(screen.getByRole('checkbox', { name: '行号' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-show-gutter', 'true')
expect(editor).toHaveAttribute('data-show-line-numbers', 'true')
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBe('true')
})
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
expect(mocks.apiPost).not.toHaveBeenCalled()
await user.click(screen.getByRole('checkbox', { name: '行号' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-show-gutter', 'false')
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBe('false')
})
})
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
const user = userEvent.setup()
await renderWordsView()
const editor = await screen.findByTestId('words-ace-editor')
await waitFor(() => expect(editor).toHaveAttribute('data-value', 'alpha\nbeta'))
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
})
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
expect(mocks.apiPost).not.toHaveBeenCalled()
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
})
})
it('shows the switch only for custom identifiers', async () => {
const user = userEvent.setup()
await renderWordsView()
await screen.findByTestId('words-ace-editor')
const releaseGroupButtons = screen.getAllByRole('button', { name: /自定义制作组\/字幕组/ })
await user.click(releaseGroupButtons[0])
expect(screen.queryByRole('checkbox', { name: '行号' })).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: '语法高亮' })).not.toBeInTheDocument()
expect(screen.queryByTestId('words-ace-editor')).not.toBeInTheDocument()
expect(mocks.apiPost).not.toHaveBeenCalled()
})
})