diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index e87447b091..34b4f69704 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -20,9 +20,9 @@ jobs: name: TypeScript runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: { node-version: '18' } - name: Install dependencies run: npm ci - name: Check TypeScript @@ -35,10 +35,9 @@ jobs: jest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '14' + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: { node-version: '18' } - name: Install globals run: npm install --global npm@8 - name: Install dependencies diff --git a/.github/workflows/js-lint.yml b/.github/workflows/js-lint.yml index 0117e9f49b..3fc2138a10 100644 --- a/.github/workflows/js-lint.yml +++ b/.github/workflows/js-lint.yml @@ -32,9 +32,9 @@ jobs: name: eslint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: { node-version: '18' } - name: Install dependencies run: npm ci - name: eslint @@ -44,9 +44,9 @@ jobs: name: prettier runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: { node-version: '18' } - name: Install dependencies run: npm ci - name: prettier diff --git a/.github/workflows/tag-admin.yml b/.github/workflows/tag-admin.yml index 3fda100df0..d6ad2b9955 100644 --- a/.github/workflows/tag-admin.yml +++ b/.github/workflows/tag-admin.yml @@ -22,10 +22,9 @@ jobs: name: Test & build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '14' + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: { node-version: '18' } - name: Install globals run: npm install --global npm@8 - name: Install dependencies diff --git a/package-lock.json b/package-lock.json index 3d74f0d1c0..222a792f99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1913,13 +1913,13 @@ "dev": true }, "node_modules/@codemirror/autocomplete": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.7.1.tgz", - "integrity": "sha512-hSxf9S0uB+GV+gBsjY1FZNo53e1FFdzPceRfCfD1gWOnV6o21GfB5J5Wg9G/4h76XZMPrF0A6OCK/Rz5+V1egg==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.9.1.tgz", + "integrity": "sha512-yma56tqD7khIZK4gy4X5lX3/k5ArMiCGat7HEWRF/8L2kqOjVdp2qKZqpcJjwTIjSj6fqKAHqi7IjtH3QFE+Bw==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.6.0", + "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" }, "peerDependencies": { @@ -1930,9 +1930,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.2.4.tgz", - "integrity": "sha512-42lmDqVH0ttfilLShReLXsDfASKLXzfyC36bzwcqzox9PlHulMcsUOfHXNo2X2aFMVNUoQ7j+d4q5bnfseYoOA==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.2.5.tgz", + "integrity": "sha512-dSi7ow2P2YgPBZflR9AJoaTHvqmeGIgkhignYMd5zK5y6DANTvxKxp6eMEpIDUJkRAaOY/TFZ4jP1ADIO/GLVA==", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.2.0", @@ -1941,9 +1941,9 @@ } }, "node_modules/@codemirror/language": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.7.0.tgz", - "integrity": "sha512-4SMwe6Fwn57klCUsVN0y4/h/iWT+XIXFEmop2lIHHuWO0ubjCrF3suqSZLyOQlznxkNnNbOOfKe5HQbQGCAmTg==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.9.0.tgz", + "integrity": "sha512-nFu311/0ne/qGuGCL3oKuktBgzVOaxCHZPZv1tLSZkNjPYxxvkjSbzno3MlErG2tgw1Yw1yF8BxMCegeMXqpiw==", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -1959,12 +1959,12 @@ "integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==" }, "node_modules/@codemirror/view": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.12.0.tgz", - "integrity": "sha512-xNHvbJBc2v8JuEcIGOck6EUGShpP+TYGCEMVEVQMYxbFXfMhYnoF3znxB/2GgeKR0nrxBs+nhBupiTYQqCp2kw==", + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.19.0.tgz", + "integrity": "sha512-XqNIfW/3GaaF+T7Q1jBcRLCPm1NbrR2DBxrXacSt1FG+rNsdsNn3/azAfgpUoJ7yy4xgd8xTPa3AlL+y0lMizQ==", "dependencies": { "@codemirror/state": "^6.1.4", - "style-mod": "^4.0.0", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -12060,9 +12060,9 @@ } }, "node_modules/style-mod": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.3.tgz", - "integrity": "sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==" }, "node_modules/style-to-js": { "version": "1.1.1", @@ -13031,11 +13031,11 @@ }, "translate": { "dependencies": { - "@codemirror/autocomplete": "^6.7.1", - "@codemirror/commands": "^6.2.4", - "@codemirror/language": "^6.7.0", + "@codemirror/autocomplete": "^6.9.1", + "@codemirror/commands": "^6.2.5", + "@codemirror/language": "^6.9.0", "@codemirror/state": "^6.2.1", - "@codemirror/view": "^6.12.0", + "@codemirror/view": "^6.19.0", "@fluent/bundle": "^0.18.0", "@fluent/langneg": "^0.7.0", "@fluent/react": "^0.15.1", diff --git a/translate/package.json b/translate/package.json index 426127c0ad..c185c57789 100644 --- a/translate/package.json +++ b/translate/package.json @@ -2,11 +2,11 @@ "name": "translate", "private": true, "dependencies": { - "@codemirror/autocomplete": "^6.7.1", - "@codemirror/commands": "^6.2.4", - "@codemirror/language": "^6.7.0", + "@codemirror/autocomplete": "^6.9.1", + "@codemirror/commands": "^6.2.5", + "@codemirror/language": "^6.9.0", "@codemirror/state": "^6.2.1", - "@codemirror/view": "^6.12.0", + "@codemirror/view": "^6.19.0", "@fluent/bundle": "^0.18.0", "@fluent/langneg": "^0.7.0", "@fluent/react": "^0.15.1", diff --git a/translate/src/modules/translationform/components/TranslationForm.css b/translate/src/modules/translationform/components/TranslationForm.css index 3e15bbfadf..6287a02b35 100644 --- a/translate/src/modules/translationform/components/TranslationForm.css +++ b/translate/src/modules/translationform/components/TranslationForm.css @@ -84,12 +84,13 @@ background: white; color: var(--dark-grey-4); font-size: 14px; - padding: 6px 8px 6px 4px; + padding: 6px 0; } .cm-editor .cm-scroller { font-family: inherit; line-height: 22px; + padding: 0 8px 0 4px; } .singlefield .cm-content, @@ -102,6 +103,10 @@ padding: 0; } +.translationform .cm-scroller { + padding: 0; +} + .translationform .cm-content { padding: 2px 0; } diff --git a/translate/src/modules/translationform/utils/decoratorPlugin.ts b/translate/src/modules/translationform/utils/decoratorPlugin.ts new file mode 100644 index 0000000000..411933bf41 --- /dev/null +++ b/translate/src/modules/translationform/utils/decoratorPlugin.ts @@ -0,0 +1,142 @@ +import { syntaxTree } from '@codemirror/language'; +import { Prec, RangeSetBuilder } from '@codemirror/state'; +import { + Decoration, + DecorationSet, + Direction, + EditorView, + ViewPlugin, + ViewUpdate, +} from '@codemirror/view'; +import type { Tree } from '@lezer/common'; + +/** Use content-based automatic direction for values inside quotes */ +const dirAuto = Decoration.mark({ attributes: { dir: 'auto' } }); + +/** Explicitly mark placeholders and tags as LTR spans, for bidirectional contexts */ +const dirLTR = Decoration.mark({ + attributes: { dir: 'ltr' }, + bidiIsolate: Direction.LTR, +}); + +/** Enable spellchecking only for string content, and not highlighted syntax or quoted literals */ +const spellcheck = Decoration.mark({ attributes: { spellcheck: 'true' } }); + +/** + * Because decorators may be nested, they need to be tracked separately + * so that we can assign appropriate precedences to them later. + * In the worst case, we'll have a dir=LTR tag in a dir=RTL message + * containing a dir=auto quoted literal with a dir=LTR placeholder. + * Because placeholders may also contain quoted literals, + * the placeholders inside & outside literals need different precedence. + * Luckily no format we cares about allows for + * placeholders within quoted literals within placeholders. + */ +const getDecorations = (view: EditorView) => { + const phIn = new RangeSetBuilder(); // placeholders inside quotes + const lit = new RangeSetBuilder(); // quoted literals + const phOut = new RangeSetBuilder(); // placeholders outside quotes + const ts = new RangeSetBuilder(); // tags and spellcheck + let ph = phOut; + let quoteStart = -1; + let phStart = -1; + let tagStart = -1; + let end = -1; + syntaxTree(view.state).iterate({ + enter(node) { + switch (node.name) { + case 'Document': + end = node.to; + break; + case 'keyword': + ph.add(node.from, node.to, dirLTR); + break; + case 'brace': + if (phStart === -1) { + phStart = node.from; + } else { + ph.add(phStart, node.to, dirLTR); + phStart = -1; + } + break; + case 'quote': + if (quoteStart === -1) { + ph = phIn; + quoteStart = node.to; + } else { + if (node.from > quoteStart) { + lit.add(quoteStart, node.from, dirAuto); + } + ph = phOut; + quoteStart = -1; + } + break; + case 'string': + ts.add(node.from, node.to, spellcheck); + break; + case 'bracket': + if (tagStart === -1) { + tagStart = node.from; + } else { + ts.add(tagStart, node.to, dirLTR); + tagStart = -1; + } + break; + } + }, + }); + if (phStart !== -1 && end > phStart) { + ph.add(phStart, end, dirLTR); + } + if (quoteStart !== -1 && end > quoteStart) { + lit.add(quoteStart, end, dirAuto); + } + if (tagStart !== -1 && end > tagStart) { + ts.add(tagStart, end, dirLTR); + } + return { + literals: lit.finish(), + placeholdersOutsideQuotes: phOut.finish(), + placeholdersInsideQuotes: phIn.finish(), + tagsAndSpellcheck: ts.finish(), + }; +}; + +export const decoratorPlugin = ViewPlugin.fromClass( + class { + decorations: ReturnType; + tree: Tree; + constructor(view: EditorView) { + this.decorations = getDecorations(view); + this.tree = syntaxTree(view.state); + } + update(update: ViewUpdate) { + if (update.docChanged || syntaxTree(update.state) != this.tree) { + this.decorations = getDecorations(update.view); + this.tree = syntaxTree(update.state); + } + } + }, + { + provide(plugin) { + const list = ( + get: (deco: ReturnType) => DecorationSet, + ) => { + const get_ = (view: EditorView) => { + const pi = view.plugin(plugin); + return pi ? get(pi.decorations) : Decoration.none; + }; + return [ + EditorView.decorations.of(get_), + EditorView.bidiIsolatedRanges.of(get_), + ]; + }; + return [ + Prec.high(list((deco) => deco.placeholdersInsideQuotes)), + Prec.default(list((deco) => deco.literals)), + Prec.low(list((deco) => deco.placeholdersOutsideQuotes)), + Prec.lowest(list((deco) => deco.tagsAndSpellcheck)), + ]; + }, + }, +); diff --git a/translate/src/modules/translationform/utils/editFieldExtensions.ts b/translate/src/modules/translationform/utils/editFieldExtensions.ts index d7c2a0ec1b..a94be6e7a2 100644 --- a/translate/src/modules/translationform/utils/editFieldExtensions.ts +++ b/translate/src/modules/translationform/utils/editFieldExtensions.ts @@ -10,28 +10,21 @@ import { StreamLanguage, bracketMatching, syntaxHighlighting, - syntaxTree, } from '@codemirror/language'; -import { Extension, Range } from '@codemirror/state'; -import { - Decoration, - DecorationSet, - EditorView, - ViewPlugin, - ViewUpdate, - keymap, -} from '@codemirror/view'; +import { Extension } from '@codemirror/state'; +import { EditorView, keymap } from '@codemirror/view'; +import { tags } from '@lezer/highlight'; import { useContext, useEffect, useRef } from 'react'; import { EditorActions } from '~/context/Editor'; import { useCopyOriginalIntoEditor } from '~/modules/editor'; +import { decoratorPlugin } from './decoratorPlugin'; import { useHandleCtrlShiftArrow, useHandleEnter, useHandleEscape, } from './editFieldShortcuts'; import { fluentMode, commonMode } from './editFieldModes'; -import { tags } from '@lezer/highlight'; /** * Key handlers depend on application state, @@ -66,40 +59,24 @@ export function useKeyHandlers() { } const style = HighlightStyle.define([ - { tag: tags.keyword, color: '#872bff', fontFamily: 'monospace' }, // printf - { tag: tags.tagName, color: '#3e9682', fontFamily: 'monospace' }, // <...> - { tag: tags.brace, color: '#872bff', fontWeight: 'bold' }, // {...} - { tag: tags.name, color: '#872bff' }, // {...} + { + tag: tags.keyword, + color: '#872bff', + fontFamily: 'monospace', + whiteSpace: 'pre', + }, // printf + { + tag: [tags.bracket, tags.tagName], + color: '#3e9682', + fontFamily: 'monospace', + whiteSpace: 'pre', + }, // <...> + { tag: tags.brace, color: '#872bff', fontWeight: 'bold', whiteSpace: 'pre' }, // { } + { tag: tags.name, color: '#872bff', whiteSpace: 'pre' }, // {...} + { tag: [tags.quote, tags.literal], whiteSpace: 'pre' }, // "..." + { tag: tags.string, whiteSpace: 'pre-line' }, ]); -// Enable spellchecking only for string content, and not highlighted syntax -const spellcheckMark = Decoration.mark({ attributes: { spellcheck: 'true' } }); -const spellcheckPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = this.getDecorations(view); - } - update(update: ViewUpdate) { - if (update.docChanged) { - this.decorations = this.getDecorations(update.view); - } - } - private getDecorations(view: EditorView) { - const deco: Range[] = []; - syntaxTree(view.state).iterate({ - enter(node) { - if (node.name == 'string') { - deco.push(spellcheckMark.range(node.from, node.to)); - } - }, - }); - return Decoration.set(deco); - } - }, - { decorations: (v) => v.decorations }, -); - export const getExtensions = ( format: string, ref: ReturnType, @@ -110,7 +87,7 @@ export const getExtensions = ( EditorView.lineWrapping, StreamLanguage.define(format === 'ftl' ? fluentMode : commonMode), syntaxHighlighting(style), - spellcheckPlugin, + decoratorPlugin, keymap.of([ { key: 'Enter', diff --git a/translate/src/modules/translationform/utils/editFieldModes.ts b/translate/src/modules/translationform/utils/editFieldModes.ts index 8c4dffed7e..7860d57094 100644 --- a/translate/src/modules/translationform/utils/editFieldModes.ts +++ b/translate/src/modules/translationform/utils/editFieldModes.ts @@ -1,64 +1,120 @@ import { StreamParser } from '@codemirror/language'; -export const fluentMode: StreamParser<{ expression: boolean; tag: boolean }> = { - name: 'fluent', - startState: () => ({ expression: false, tag: false }), - token(stream, state) { - const ch = stream.next(); - if (state.expression) { - if (ch === '}') { - state.expression = false; - return 'brace'; - } - stream.skipTo('}'); - return 'name'; - } else { - if (ch === '{') { - state.expression = true; - return 'brace'; - } - if (ch === '<') { - state.tag = true; - } - if (state.tag) { - if (ch === '>') { - state.tag = false; - } else { - stream.eatWhile(/[^>{]+/); - } - return 'tagName'; +export const fluentMode: StreamParser> = + { + name: 'fluent', + startState: () => [], + token(stream, state) { + const ch = stream.next(); + switch (state.at(-1)) { + case 'expression': + switch (ch) { + case '"': + state.push('literal'); + return 'quote'; + case '{': + state.push('expression'); + return 'brace'; + case '}': + state.pop(); + return 'brace'; + default: + stream.eatWhile(/[^"{}]+/); + return 'name'; + } + + case 'literal': + switch (ch) { + case '"': + state.pop(); + return 'quote'; + case '{': + state.push('expression'); + return 'brace'; + default: + stream.eatWhile(/[^"{]+/); + return 'literal'; + } + + case 'tag': + switch (ch) { + case '"': + state.push('literal'); + return 'quote'; + case '{': + state.push('expression'); + return 'brace'; + case '>': + state.pop(); + return 'bracket'; + default: + stream.eatWhile(/[^"{>]+/); + return 'tagName'; + } + + default: + switch (ch) { + case '{': + state.push('expression'); + return 'brace'; + case '<': + state.push('tag'); + return 'bracket'; + default: + stream.eatWhile(/[^<{]+/); + return 'string'; + } } - stream.eatWhile(/[^<{]+/); - return 'string'; - } - }, -}; + }, + }; const printf = /^%(\d\$|\(.*?\))?[-+ 0'#]*[\d*]*(\.[\d*])?(hh?|ll?|[jLtz])?[%@AacdEeFfGginopSsuXx]/; const pythonFormat = /^{[\w.[\]]*(![rsa])?(:.*?)?}/; -export const commonMode: StreamParser<{ tag: boolean }> = { +export const commonMode: StreamParser> = { name: 'common', - startState: () => ({ tag: false }), + startState: () => [], token(stream, state) { if (stream.match(printf) || stream.match(pythonFormat)) { return 'keyword'; } + const ch = stream.next(); - if (ch === '<') { - state.tag = true; - } - if (state.tag) { - if (ch === '>') { - state.tag = false; - } else { - stream.eatWhile(/[^>%{]+/); - } - return 'tagName'; + switch (state.at(-1)) { + case 'literal': + switch (ch) { + case '"': + state.pop(); + return 'quote'; + default: + stream.eatWhile(/[^%{"]+/); + return 'literal'; + } + + case 'tag': + switch (ch) { + case '"': + state.push('literal'); + return 'quote'; + case '>': + state.pop(); + return 'bracket'; + default: + stream.eatWhile(/[^%{">]+/); + return 'tagName'; + } + + default: + switch (ch) { + case '<': + state.push('tag'); + return 'bracket'; + default: + stream.eatWhile(/[^%{<]+/); + return 'string'; + } } - stream.eatWhile(/[^%{<]+/); - return 'string'; }, };