diff --git a/package-lock.json b/package-lock.json index 2d7cbe4067..3d74f0d1c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1912,6 +1912,62 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "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==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.6.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.2.4.tgz", + "integrity": "sha512-42lmDqVH0ttfilLShReLXsDfASKLXzfyC36bzwcqzox9PlHulMcsUOfHXNo2X2aFMVNUoQ7j+d4q5bnfseYoOA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.2.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "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==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.1.tgz", + "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==", + "dependencies": { + "@codemirror/state": "^6.1.4", + "style-mod": "^4.0.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", @@ -2981,6 +3037,27 @@ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, + "node_modules/@lezer/common": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", + "integrity": "sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==" + }, + "node_modules/@lezer/highlight": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.6.tgz", + "integrity": "sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.5.tgz", + "integrity": "sha512-Kye0rxYBi+OdToLUN2tQfeH5VIrpESC6XznuvxmIxbO1lz6M1C90vkjMNYoX1SfbUcuvoPXvLYsBquZ//77zVQ==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@messageformat/fluent": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@messageformat/fluent/-/fluent-0.4.1.tgz", @@ -11982,6 +12059,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==" + }, "node_modules/style-to-js": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.1.tgz", @@ -12606,6 +12688,11 @@ "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, + "node_modules/w3c-keyname": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.7.tgz", + "integrity": "sha512-XB8aa62d4rrVfoZYQaYNy3fy+z4nrfy2ooea3/0BnBzXW0tSdZ+lRgjzBZhk0La0H6h8fVyYCxx/qkQcAIuvfg==" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -12944,10 +13031,16 @@ }, "translate": { "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/commands": "^6.2.4", + "@codemirror/language": "^6.7.0", + "@codemirror/state": "^6.2.1", + "@codemirror/view": "^6.12.0", "@fluent/bundle": "^0.18.0", "@fluent/langneg": "^0.7.0", "@fluent/react": "^0.15.1", "@fluent/syntax": "^0.19.0", + "@lezer/highlight": "^1.1.6", "@messageformat/fluent": "^0.4.1", "@reduxjs/toolkit": "^1.6.1", "classnames": "^2.3.1", diff --git a/translate/.eslintrc.js b/translate/.eslintrc.js index c53b62c4b8..946aa041c5 100644 --- a/translate/.eslintrc.js +++ b/translate/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { '@typescript-eslint/no-empty-function': 0, '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/no-inferrable-types': 0, + '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_' }], '@typescript-eslint/prefer-as-const': 0, 'import/no-default-export': 'error', }, diff --git a/translate/package.json b/translate/package.json index 95fce8e8b2..426127c0ad 100644 --- a/translate/package.json +++ b/translate/package.json @@ -2,10 +2,16 @@ "name": "translate", "private": true, "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/commands": "^6.2.4", + "@codemirror/language": "^6.7.0", + "@codemirror/state": "^6.2.1", + "@codemirror/view": "^6.12.0", "@fluent/bundle": "^0.18.0", "@fluent/langneg": "^0.7.0", "@fluent/react": "^0.15.1", "@fluent/syntax": "^0.19.0", + "@lezer/highlight": "^1.1.6", "@messageformat/fluent": "^0.4.1", "@reduxjs/toolkit": "^1.6.1", "classnames": "^2.3.1", diff --git a/translate/rollup.config.mjs b/translate/rollup.config.mjs index d60e8e6764..4e242af95b 100644 --- a/translate/rollup.config.mjs +++ b/translate/rollup.config.mjs @@ -10,7 +10,7 @@ import css from 'rollup-plugin-css-only'; /** @type {import('rollup').RollupOptions} */ const config = { input: 'src/index.tsx', - output: { file: 'dist/translate.js' }, + output: { file: 'dist/translate.js', format: 'iife' }, treeshake: 'recommended', diff --git a/translate/src/context/Editor.test.js b/translate/src/context/Editor.test.js index b3f418f0f8..6a85900ac1 100644 --- a/translate/src/context/Editor.test.js +++ b/translate/src/context/Editor.test.js @@ -6,7 +6,12 @@ import { act } from 'react-dom/test-utils'; import { createReduxStore, mountComponentWithStore } from '~/test/store'; import { editMessageEntry, parseEntry } from '~/utils/message'; -import { EditorActions, EditorData, EditorProvider } from './Editor'; +import { + EditorActions, + EditorData, + EditorProvider, + EditorResult, +} from './Editor'; import { EntityView, EntityViewProvider } from './EntityView'; import { Locale } from './Locale'; import { Location, LocationProvider } from './Location'; @@ -68,37 +73,64 @@ function mountSpy(Spy, format, translation) { describe('', () => { it('provides a simple non-Fluent value', () => { - let editor; + let editor, result; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); return null; }; mountSpy(Spy, 'simple', 'message'); expect(editor).toMatchObject({ sourceView: false, - initial: [{ id: '', keys: [], labels: [], name: '', value: 'message' }], - value: [{ id: '', keys: [], labels: [], name: '', value: 'message' }], + initial: { + id: 'key', + value: { pattern: { body: [{ type: 'text', value: 'message' }] } }, + }, + fields: [ + { + id: '', + keys: [], + labels: [], + name: '', + handle: { current: { value: 'message' } }, + }, + ], }); + expect(result).toMatchObject([{ name: '', keys: [], value: 'message' }]); }); it('provides a simple Fluent value', () => { - let editor; + let editor, result; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); return null; }; mountSpy(Spy, 'ftl', 'key = message'); expect(editor).toMatchObject({ sourceView: false, - initial: [{ id: '', keys: [], labels: [], name: '', value: 'message' }], - value: [{ id: '', keys: [], labels: [], name: '', value: 'message' }], + initial: { + id: 'key', + value: { pattern: { body: [{ type: 'text', value: 'message' }] } }, + }, + fields: [ + { + id: '', + keys: [], + labels: [], + name: '', + handle: { current: { value: 'message' } }, + }, + ], }); + expect(result).toMatchObject([{ name: '', keys: [], value: 'message' }]); }); it('provides a rich Fluent value', () => { - let editor; + let editor, result; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); return null; }; const source = ftl` @@ -111,14 +143,23 @@ describe('', () => { `; mountSpy(Spy, 'ftl', source); - const value = editMessageEntry(parseEntry(source)); - expect(editor).toMatchObject({ sourceView: false, initial: value, value }); + const entry = parseEntry(source); + const fields = editMessageEntry(parseEntry(source)).map((field) => ({ + ...field, + handle: { current: { value: field.handle.current.value } }, + })); + expect(editor).toMatchObject({ sourceView: false, initial: entry, fields }); + expect(result).toMatchObject([ + { name: '', keys: [{ type: 'nmtoken', value: 'one' }], value: 'ONE' }, + { name: '', keys: [{ type: '*', value: 'other' }], value: 'OTHER' }, + ]); }); it('provides a forced source Fluent value', () => { - let editor; + let editor, result; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); return null; }; const source = '## comment\n'; @@ -126,19 +167,28 @@ describe('', () => { expect(editor).toMatchObject({ sourceView: true, - initial: [ - { id: '', keys: [], labels: [], name: '', value: '## comment' }, + initial: { + id: 'key', + value: { pattern: { body: [{ type: 'text', value: '## comment\n' }] } }, + }, + fields: [ + { + handle: { current: { value: '## comment' } }, + id: '', + keys: [], + labels: [], + name: '', + }, ], - value: [{ id: '', keys: [], labels: [], name: '', value: '## comment' }], }); + expect(result).toMatchObject([{ name: '', keys: [], value: '## comment' }]); }); it('updates state on entity and plural form changes', () => { - let editor; - let location; - let entity; + let editor, result, location, entity; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); location = useContext(Location); entity = useContext(EntityView); return null; @@ -149,24 +199,27 @@ describe('', () => { wrapper.update(); expect(editor).toMatchObject({ - sourceView: false, - initial: [{ id: '', keys: [], labels: [], name: '', value: 'one' }], - value: [{ id: '', keys: [], labels: [], name: '', value: 'one' }], + initial: { + value: { pattern: { body: [{ type: 'text', value: 'one' }] } }, + }, + fields: [{ handle: { current: { value: 'one' } } }], }); + expect(result).toMatchObject([{ value: 'one' }]); act(() => entity.setPluralForm(1)); wrapper.update(); expect(editor).toMatchObject({ - sourceView: false, - initial: [{ id: '', keys: [], labels: [], name: '', value: 'other' }], - value: [{ id: '', keys: [], labels: [], name: '', value: 'other' }], + initial: { + value: { pattern: { body: [{ type: 'text', value: 'other' }] } }, + }, + fields: [{ handle: { current: { value: 'other' } } }], }); + expect(result).toMatchObject([{ value: 'other' }]); }); it('clears a rich Fluent value', () => { - let editor; - let actions; + let editor, actions; const Spy = () => { editor = useContext(EditorData); actions = useContext(EditorActions); @@ -184,41 +237,36 @@ describe('', () => { wrapper.update(); expect(editor).toMatchObject({ - fields: [{ current: null }, { current: null }], sourceView: false, - value: [ + fields: [ { + handle: { current: { value: '' } }, + id: '|one', keys: [{ type: 'nmtoken', value: 'one' }], labels: [{ label: 'one', plural: true }], name: '', - value: '', }, { + handle: { current: { value: '' } }, + id: '|other', keys: [{ type: '*', value: 'other' }], labels: [{ label: 'other', plural: true }], name: '', - value: '', }, ], }); }); it('sets editor from history', () => { - let editor; - let actions; + let editor, result, actions; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); actions = useContext(EditorActions); return null; }; const wrapper = mountSpy(Spy, 'ftl', `key = VALUE\n`); - expect(editor).toMatchObject({ - fields: [{ current: null }], - sourceView: false, - value: [{ keys: [], labels: [], name: '', value: 'VALUE' }], - }); - const source = ftl` key = { $var -> @@ -230,30 +278,35 @@ describe('', () => { wrapper.update(); expect(editor).toMatchObject({ - fields: [{ current: null }, { current: null }], sourceView: false, - value: [ + fields: [ { + handle: { current: { value: 'ONE' } }, + id: '|one', keys: [{ type: 'nmtoken', value: 'one' }], labels: [{ label: 'one', plural: true }], name: '', - value: 'ONE', }, { + handle: { current: { value: 'OTHER' } }, + id: '|other', keys: [{ type: '*', value: 'other' }], labels: [{ label: 'other', plural: true }], name: '', - value: 'OTHER', }, ], }); + expect(result).toMatchObject([ + { keys: [{ type: 'nmtoken', value: 'one' }], name: '', value: 'ONE' }, + { keys: [{ type: '*', value: 'other' }], name: '', value: 'OTHER' }, + ]); }); it('toggles Fluent source view', () => { - let editor; - let actions; + let editor, result, actions; const Spy = () => { editor = useContext(EditorData); + result = useContext(EditorResult); actions = useContext(EditorActions); return null; }; @@ -269,31 +322,26 @@ describe('', () => { wrapper.update(); expect(editor).toMatchObject({ - fields: [{ current: null }], sourceView: true, - value: [{ keys: [], labels: [], name: '', value: source }], - }); - - act(() => actions.toggleSourceView()); - wrapper.update(); - - expect(editor).toMatchObject({ - fields: [{ current: null }, { current: null }], - sourceView: false, - value: [ + fields: [ { - keys: [{ type: 'nmtoken', value: 'one' }], - labels: [{ label: 'one', plural: true }], + handle: { current: { value: source } }, + id: '', + keys: [], + labels: [], name: '', - value: 'ONE', - }, - { - keys: [{ type: '*', value: 'other' }], - labels: [{ label: 'other', plural: true }], - name: '', - value: 'OTHER', }, ], }); + expect(result).toMatchObject([{ keys: [], name: '', value: source }]); + + act(() => actions.toggleSourceView()); + wrapper.update(); + + expect(editor).toMatchObject({ fields: [{}, {}], sourceView: false }); + expect(result).toMatchObject([ + { keys: [{ type: 'nmtoken', value: 'one' }], name: '', value: 'ONE' }, + { keys: [{ type: '*', value: 'other' }], name: '', value: 'OTHER' }, + ]); }); }); diff --git a/translate/src/context/Editor.tsx b/translate/src/context/Editor.tsx index 28a423d36d..f08f50cb71 100644 --- a/translate/src/context/Editor.tsx +++ b/translate/src/context/Editor.tsx @@ -8,11 +8,12 @@ import React, { } from 'react'; import type { SourceType } from '~/api/machinery'; -import { useTranslationStatus } from '~/modules/entities/useTranslationStatus'; import { useReadonlyEditor } from '~/hooks/useReadonlyEditor'; +import { useTranslationStatus } from '~/modules/entities/useTranslationStatus'; import { buildMessageEntry, editMessageEntry, + editSource, requiresSourceView, getEmptyMessageEntry, MessageEntry, @@ -25,9 +26,16 @@ import { EntityView, useActiveTranslation } from './EntityView'; import { FailedChecksData } from './FailedChecksData'; import { Locale } from './Locale'; import { MachineryTranslations } from './MachineryTranslations'; -import { UnsavedActions, UnsavedChanges } from './UnsavedChanges'; +import { UnsavedActions } from './UnsavedChanges'; + +export type EditFieldHandle = { + get value(): string; + focus(): void; + setSelection(text: string): void; + setValue(text: string): void; +}; -export type EditorMessage = Array<{ +export type EditorField = { /** An identifier for this field */ id: string; @@ -39,82 +47,33 @@ export type EditorMessage = Array<{ labels: Array<{ label: string; plural: boolean }>; + handle: React.MutableRefObject; +}; + +export type EditorData = Readonly<{ /** - * A flattened representation of a single message pattern, - * which may contain syntactic representations of placeholders. + * Should match `useContext(EntityView).pk`. + * If it doesn't, the entity has changed but data isn't updated yet. */ - value: string; -}>; - -function editSource(source: string | MessageEntry) { - const value = - typeof source === 'string' ? source : serializeEntry('ftl', source); - return [{ id: '', name: '', keys: [], labels: [], value: value.trim() }]; -} - -/** - * Creates a copy of `base` with an entry matching `id` updated to `value`. - * - * @param id If empty, matches first entry of `base`. - * If set, a path split by `|` characters. - */ -function setEditorMessage( - base: EditorMessage, - id: string | null | undefined, - value: string, -): EditorMessage { - let set = false; - return base.map((field) => { - if (!set && (!id || field.id === id)) { - set = true; - return { ...field, value }; - } else { - return field; - } - }); -} - -function parseEntryFromFluentSource(base: MessageEntry, source: string) { - const entry = parseEntry(source); - if (entry) { - entry.id = base.id; - } - return entry; -} - -/** - * Create a new MessageEntry with a simple string pattern `value`, - * using `id` as its identifier. - */ -const createSimpleMessageEntry = (id: string, value: string): MessageEntry => ({ - id, - value: { - type: 'message', - declarations: [], - pattern: { body: [{ type: 'text', value }] }, - }, -}); + pk: number; -export type EditorData = Readonly<{ /** Is a request to send a new translation running? */ busy: boolean; /** Used to reconstruct edited messages */ entry: MessageEntry; - /** Editor input components */ - fields: Array< - React.MutableRefObject - >; + /** Input fields for the value being edited */ + fields: EditorField[]; /** - * Index in `fields` of the current or most recent field with focus; + * The current or most recent field with focus; * used as the target of machinery replacements. */ - focusField: React.MutableRefObject; + focusField: React.MutableRefObject; /** Used for detecting unsaved changes */ - initial: EditorMessage; + initial: MessageEntry; machinery: { manual: boolean; @@ -123,9 +82,20 @@ export type EditorData = Readonly<{ } | null; sourceView: boolean; +}>; + +export type EditorResult = Array<{ + /** Attribute name, or empty for the value */ + name: string; + + /** Selector keys, or empty array for single-pattern messages */ + keys: Variant['keys']; - /** The current value being edited */ - value: EditorMessage; + /** + * A flattened representation of a single message pattern, + * which may contain syntactic representations of placeholders. + */ + value: string; }>; export type EditorActions = { @@ -136,9 +106,6 @@ export type EditorActions = { /** If `format: 'ftl'`, must be called with the source of a full entry */ setEditorFromHistory(value: string): void; - /** For `view: 'rich'`, if `value` is a string, sets the value of the active input */ - setEditorFromInput(value: string | EditorMessage): void; - /** @param manual Set `true` when value set due to direct user action */ setEditorFromHelpers( value: string, @@ -148,18 +115,42 @@ export type EditorActions = { setEditorSelection(content: string): void; + /** Set the result value of the active input */ + setResultFromInput(idx: number, value: string): void; + toggleSourceView(): void; }; +function parseEntryFromFluentSource(base: MessageEntry, source: string) { + const entry = parseEntry(source); + if (entry) { + entry.id = base.id; + } + return entry; +} + +/** + * Create a new MessageEntry with a simple string pattern `value`, + * using `id` as its identifier. + */ +const createSimpleMessageEntry = (id: string, value: string): MessageEntry => ({ + id, + value: { + type: 'message', + declarations: [], + pattern: { body: [{ type: 'text', value }] }, + }, +}); + const initEditorData: EditorData = { + pk: 0, busy: false, entry: { id: '', value: null, attributes: new Map() }, - fields: [], - focusField: { current: 0 }, - initial: [], + focusField: { current: null }, + initial: { id: '', value: null, attributes: new Map() }, machinery: null, + fields: [], sourceView: false, - value: [], }; const initEditorActions: EditorActions = { @@ -167,14 +158,22 @@ const initEditorActions: EditorActions = { setEditorBusy: () => {}, setEditorFromHelpers: () => {}, setEditorFromHistory: () => {}, - setEditorFromInput: () => {}, setEditorSelection: () => {}, + setResultFromInput: () => {}, toggleSourceView: () => {}, }; export const EditorData = createContext(initEditorData); +export const EditorResult = createContext([]); export const EditorActions = createContext(initEditorActions); +const buildResult = (message: EditorField[]): EditorResult => + message.map(({ handle, keys, name }) => ({ + name, + keys, + value: handle.current.value, + })); + export function EditorProvider({ children }: { children: React.ReactElement }) { const locale = useContext(Locale); const { entity } = useContext(EntityView); @@ -182,37 +181,44 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { const readonly = useReadonlyEditor(); const machinery = useContext(MachineryTranslations); const { setUnsavedChanges } = useContext(UnsavedActions); - const { exist } = useContext(UnsavedChanges); const { resetFailedChecks } = useContext(FailedChecksData); - const [state, setState] = useState(initEditorData); + const [state, setState] = useState(initEditorData); + const [result, setResult] = useState([]); const actions = useMemo(() => { if (readonly) { return initEditorActions; } return { - clearEditor: () => - setState((prev) => { - const empty = prev.value.map((field) => ({ ...field, value: '' })); - return { ...prev, value: empty }; - }), + clearEditor() { + setState((state) => { + for (const field of state.fields) { + field.handle.current.setValue(''); + } + return state; + }); + }, setEditorBusy: (busy) => setState((prev) => (busy === prev.busy ? prev : { ...prev, busy })), setEditorFromHelpers: (str, sources, manual) => setState((prev) => { - const { fields, focusField, sourceView, value } = prev; - const input = fields[focusField.current]?.current; - let next = setEditorMessage(value, input?.id, str); + const { fields, focusField, sourceView } = prev; + const field = focusField.current ?? fields[0]; + field.handle.current.setValue(str); + let next = fields.slice(); if (sourceView) { - next = editSource(buildMessageEntry(prev.entry, next)); + const result = buildResult(next); + next = editSource(buildMessageEntry(prev.entry, result)); + focusField.current = next[0]; + setResult(result); } return { ...prev, machinery: { manual, translation: str, sources }, - value: next, + fields: next, }; }), @@ -225,80 +231,62 @@ export function EditorProvider({ children }: { children: React.ReactElement }) { next.entry = entry; } if (entry && !requiresSourceView(entry)) { - next.value = prev.sourceView + next.fields = prev.sourceView ? editSource(entry) : editMessageEntry(entry); } else { - next.value = editSource(str); + next.fields = editSource(str); next.sourceView = true; } - next.fields = next.value.map(() => ({ current: null })); } else { - next.value = setEditorMessage(prev.initial, null, str); + next.fields = editMessageEntry(prev.initial); + next.fields[0].handle.current.setValue(str); } + next.focusField.current = next.fields[0]; + setResult(buildResult(next.fields)); return next; }), - setEditorFromInput: (input) => - setState((prev) => { - if (typeof input === 'string') { - const { fields, focusField, value } = prev; - const field = fields[focusField.current]?.current; - const next = setEditorMessage(value, field?.id, input); - return { ...prev, value: next }; - } else { - return { ...prev, value: input }; - } + setEditorSelection: (content) => + setState((state) => { + const { fields, focusField } = state; + const field = focusField.current ?? fields[0]; + field.handle.current.setSelection(content); + return state; }), - setEditorSelection: (content) => - setState((prev) => { - const { fields, focusField, sourceView, value } = prev; - let next: EditorMessage; - const input = fields[focusField.current]?.current; - if (input) { - input.setRangeText( - content, - input.selectionStart ?? 0, // never actually null for or