Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace editor <textarea> with CodeMirror v6 #2866

Merged
merged 4 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions translate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
"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",
Expand Down
2 changes: 1 addition & 1 deletion translate/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the new dependencies includes a module with a top-level statement const top = .... With the default output.format, Rollup presumes that the code will be included in a <script type=module> so leaves it at the top level. However, as we don't do that, the output needs to be wrapped in an IIFE.


treeshake: 'recommended',

Expand Down
19 changes: 6 additions & 13 deletions translate/src/context/Editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,9 @@ describe('<EditorProvider>', () => {
});

it('clears a rich Fluent value', () => {
let editor, result, actions;
let editor, actions;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
actions = useContext(EditorActions);
return null;
};
Expand Down Expand Up @@ -256,10 +255,6 @@ describe('<EditorProvider>', () => {
},
],
});
expect(result).toMatchObject([
{ name: '', keys: [{ type: 'nmtoken', value: 'one' }], value: '' },
{ name: '', keys: [{ type: '*', value: 'other' }], value: '' },
]);
});

it('sets editor from history', () => {
Expand All @@ -272,8 +267,6 @@ describe('<EditorProvider>', () => {
};
const wrapper = mountSpy(Spy, 'ftl', `key = VALUE\n`);

expect(result).toMatchObject([{ name: '', keys: [], value: 'VALUE' }]);

const source = ftl`
key =
{ $var ->
Expand Down Expand Up @@ -304,8 +297,8 @@ describe('<EditorProvider>', () => {
],
});
expect(result).toMatchObject([
{ name: '', keys: [{ type: 'nmtoken', value: 'one' }], value: 'ONE' },
{ name: '', keys: [{ type: '*', value: 'other' }], value: 'OTHER' },
{ keys: [{ type: 'nmtoken', value: 'one' }], name: '', value: 'ONE' },
{ keys: [{ type: '*', value: 'other' }], name: '', value: 'OTHER' },
]);
});

Expand Down Expand Up @@ -340,15 +333,15 @@ describe('<EditorProvider>', () => {
},
],
});
expect(result).toMatchObject([{ name: '', keys: [], value: source }]);
expect(result).toMatchObject([{ keys: [], name: '', value: source }]);

act(() => actions.toggleSourceView());
wrapper.update();

expect(editor).toMatchObject({ fields: [{}, {}], sourceView: false });
expect(result).toMatchObject([
{ name: '', keys: [{ type: 'nmtoken', value: 'one' }], value: 'ONE' },
{ name: '', keys: [{ type: '*', value: 'other' }], value: 'OTHER' },
{ keys: [{ type: 'nmtoken', value: 'one' }], name: '', value: 'ONE' },
{ keys: [{ type: '*', value: 'other' }], name: '', value: 'OTHER' },
]);
});
});
53 changes: 30 additions & 23 deletions translate/src/context/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import { UnsavedActions } from './UnsavedChanges';

export type EditFieldHandle = {
get value(): string;
set value(next: string);
focus(): void;
setSelection(content: string): void;
setSelection(text: string): void;
setValue(text: string): void;
};

export type EditorField = {
Expand All @@ -51,6 +51,12 @@ export type EditorField = {
};

export type EditorData = Readonly<{
/**
* Should match `useContext(EntityView).pk`.
* If it doesn't, the entity has changed but data isn't updated yet.
*/
pk: number;

/** Is a request to send a new translation running? */
busy: boolean;

Expand Down Expand Up @@ -100,9 +106,6 @@ export type EditorActions = {
/** If `format: 'ftl'`, must be called with the source of a full entry */
setEditorFromHistory(value: string): void;

/** Set the result value of the active input */
setEditorFromInput(idx: number, value: string): void;

/** @param manual Set `true` when value set due to direct user action */
setEditorFromHelpers(
value: string,
Expand All @@ -112,6 +115,9 @@ export type EditorActions = {

setEditorSelection(content: string): void;

/** Set the result value of the active input */
setResultFromInput(idx: number, value: string): void;

toggleSourceView(): void;
};

Expand All @@ -137,6 +143,7 @@ const createSimpleMessageEntry = (id: string, value: string): MessageEntry => ({
});

const initEditorData: EditorData = {
pk: 0,
busy: false,
entry: { id: '', value: null, attributes: new Map() },
focusField: { current: null },
Expand All @@ -151,8 +158,8 @@ const initEditorActions: EditorActions = {
setEditorBusy: () => {},
setEditorFromHelpers: () => {},
setEditorFromHistory: () => {},
setEditorFromInput: () => {},
setEditorSelection: () => {},
setResultFromInput: () => {},
toggleSourceView: () => {},
};

Expand Down Expand Up @@ -188,13 +195,10 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
clearEditor() {
setState((state) => {
for (const field of state.fields) {
field.handle.current.value = '';
field.handle.current.setValue('');
}
return state;
});
setResult((result) =>
result.map(({ name, keys }) => ({ name, keys, value: '' })),
);
},

setEditorBusy: (busy) =>
Expand All @@ -204,15 +208,14 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
setState((prev) => {
const { fields, focusField, sourceView } = prev;
const field = focusField.current ?? fields[0];
field.handle.current.value = str;
field.handle.current.setValue(str);
let next = fields.slice();
let result = buildResult(next);
if (sourceView) {
const result = buildResult(next);
next = editSource(buildMessageEntry(prev.entry, result));
focusField.current = next[0];
result = buildResult(next);
setResult(result);
}
setResult(result);
return {
...prev,
machinery: { manual, translation: str, sources },
Expand All @@ -238,29 +241,32 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
}
} else {
next.fields = editMessageEntry(prev.initial);
next.fields[0].handle.current.value = str;
next.fields[0].handle.current.setValue(str);
}
next.focusField.current = next.fields[0];
setResult(buildResult(next.fields));
return next;
}),

setEditorFromInput: (idx, value) =>
setResult((prev) => {
const res = prev.slice();
res[idx] = { ...res[idx], value };
return res;
}),

setEditorSelection: (content) =>
setState((state) => {
const { fields, focusField } = state;
const field = focusField.current ?? fields[0];
field.handle.current.setSelection(content);
setResult(buildResult(fields));
return state;
}),

setResultFromInput: (idx, value) =>
setResult((prev) => {
if (prev.length > idx) {
const res = prev.slice();
res[idx] = { ...res[idx], value };
return res;
} else {
return prev;
}
}),

toggleSourceView: () =>
setState((prev) => {
if (prev.sourceView) {
Expand Down Expand Up @@ -325,6 +331,7 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
const fields = sourceView ? editSource(source) : editMessageEntry(entry);

setState(() => ({
pk: entity.pk,
busy: false,
entry,
fields,
Expand Down
11 changes: 6 additions & 5 deletions translate/src/context/UnsavedChanges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export type UnsavedChanges = Readonly<{
}>;

export type UnsavedActions = {
/**
* The `callback` is called as `setTimout(callback)`
* to avoid an occasional React complaint about
* updating one component while rendering a different component.
*/
checkUnsavedChanges(callback: () => void): void;

/**
Expand Down Expand Up @@ -50,7 +55,7 @@ export function UnsavedChangesProvider({
if (prev.check()) {
return { check: () => true, onIgnore: callback };
} else {
callback();
setTimeout(callback);
return prev;
}
}),
Expand All @@ -60,11 +65,7 @@ export function UnsavedChangesProvider({
const { onIgnore } = prev;
if (onIgnore) {
if (ignore) {
// Needs to happen after the return to avoid an occasional React
// complaint about updating one component while rendering a
// different component.
setTimeout(onIgnore);

return { check: () => false, onIgnore: null };
}
return { ...prev, onIgnore: null };
Expand Down
Loading