From 96ef27852e48cbba2a35159197ba951cb4ed8fbe Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 23 Oct 2026 14:48:44 +0200 Subject: [PATCH 1/2] fix: link form, closing before the preview, add zone of the preview and translations - Enter in the link field no longer submits the host form (no nested form, Enter in an input never submits it) - closing the editor before the preview is loaded no longer throws - the add zone of the preview stays in the middle, away from the move and delete buttons - every text added in 0.2 goes through the translations (toolbar, link option, preview buttons, devices), with an English fallback for host translations missing a key --- visual-editor/cypress/e2e/compat.cy.js | 97 +++++++++++++++++++ visual-editor/server/compat.html | 6 ++ visual-editor/src/VisualEditor.tsx | 32 +++++- .../src/components/Blocs/BlocSelector.tsx | 22 +++-- .../Editor/TiptapEditor/TiptapColorPicker.tsx | 5 +- .../Editor/TiptapEditor/TiptapToolbar.tsx | 76 ++++++++------- .../TiptapEditor/TiptapToolbarAlign.tsx | 6 +- .../TiptapEditor/TiptapToolbarFormats.tsx | 14 +-- .../TiptapEditor/TiptapToolbarHeadings.tsx | 8 +- .../src/components/Preview/Preview.tsx | 70 +++++++------ .../components/Preview/PreviewAddButton.tsx | 9 +- .../components/Preview/PreviewAddFloating.tsx | 16 +-- .../components/Preview/PreviewItemWrapper.tsx | 4 + .../src/fields/shared/AlignmentButton.tsx | 4 +- visual-editor/src/functions/i18n.ts | 24 ++++- visual-editor/src/hooks/useAsyncEffect.ts | 7 -- visual-editor/src/langs/en.ts | 31 ++++++ visual-editor/src/langs/fr.ts | 31 ++++++ visual-editor/test/functions/i18n.test.ts | 37 +++++++ 19 files changed, 391 insertions(+), 108 deletions(-) delete mode 100644 visual-editor/src/hooks/useAsyncEffect.ts create mode 100644 visual-editor/test/functions/i18n.test.ts diff --git a/visual-editor/cypress/e2e/compat.cy.js b/visual-editor/cypress/e2e/compat.cy.js index 2f0b7360..d30f1c5f 100644 --- a/visual-editor/cypress/e2e/compat.cy.js +++ b/visual-editor/cypress/e2e/compat.cy.js @@ -304,4 +304,101 @@ describe('Compatibility with a production host', () => { cy.contains('button', 'Sauvegarder').click() cy.window().its('host.submitted').should('deep.equal', []) }) + + it('applies a link with Enter without submitting the host form', () => { + visit() + openEditor() + addBloc('Texte formaté') + cy.get('.ProseMirror').first().click() + cy.get('.ProseMirror').first().type('{selectall}') + cy.get('.WysiwygToolbar button[title="Lien"]').click() + cy.contains('.WysiwygToolbar label', 'Nouvel onglet').should( + 'have.attr', + 'title', + 'Ouvrir le lien dans un nouvel onglet' + ) + changeCount().then((count) => { + cy.focused().type('www.example.com{enter}') + expectNewChange(count, (data) => + expect(data[0].content).to.contain('href="https://www.example.com"') + ) + }) + cy.window().its('host.submitEvents').should('equal', 0) + cy.window().should((win) => expect(win.host.submitted).to.equal(null)) + }) + + it('does not submit the host form with Enter in a text field', () => { + visit() + openEditor() + addBloc('Avis google') + cy.contains('label', "ID de l'établissement sur Google") + .parent() + .find('input') + .first() + .type('abc{enter}') + cy.window().its('host.submitEvents').should('equal', 0) + lastChange().then((data) => expect(data[0].placeId).to.equal('abc')) + }) + + it('shows the text toolbar in French', () => { + visit() + openEditor() + addBloc('Texte formaté') + cy.get('.ProseMirror').first().click() + cy.get('.ProseMirror').first().type('{selectall}') + for (const title of ['Gras', 'Italique', 'Souligné', 'Barré', 'Lien']) { + cy.get(`.WysiwygToolbar button[title="${title}"]`).should('exist') + } + cy.get('.WysiwygToolbar [title="Bold"]').should('not.exist') + }) + + it('can be closed before the preview is loaded', () => { + cy.intercept('POST', '/preview*', (req) => { + req.reply({ + delay: 1500, + body: '
', + }) + }).as('slowPreview') + cy.visit('/server/compat.html') + cy.window().its('host.ready').should('be.true') + openEditor() + cy.get('[aria-label="Fermer"]').click() + cy.wait('@slowPreview') + // An uncaught error would fail the test + cy.wait(300) + }) + + it('keeps the move buttons of the preview away from the add zone', () => { + cy.fixture('app/templates/camp.json').then((camp) => { + visit(JSON.stringify(camp)) + openEditor() + cy.wait('@previewPage') + previewFrame().find('button[title="Monter le bloc"]').should('exist') + cy.get('iframe').then(($iframe) => { + const doc = $iframe[0].contentDocument + const buttons = doc.querySelectorAll('button[title="Monter le bloc"]') + expect(buttons.length).to.be.greaterThan(1) + let checked = 0 + buttons.forEach((up) => { + const r = up.getBoundingClientRect() + // Around the buttons (left, above, between): never the add zone + const points = [ + [r.left - 6, r.top + r.height / 2], + [r.left + r.width / 2, r.top - 6], + [r.right + 1, r.top + r.height / 2], + ] + for (const [x, y] of points) { + const hit = doc.elementFromPoint(x, y) + if (hit) { + checked++ + } + expect(hit?.closest('button')?.textContent ?? '').not.to.contain( + 'Ajouter un bloc' + ) + } + }) + expect(checked, 'points inside the preview').to.be.greaterThan(2) + }) + }) + }) }) diff --git a/visual-editor/server/compat.html b/visual-editor/server/compat.html index a716ad62..c28e1557 100644 --- a/visual-editor/server/compat.html +++ b/visual-editor/server/compat.html @@ -28,8 +28,14 @@ value: null, // names of the fields submitted with the form submitted: null, + // submit events seen by the page, even prevented ones (the Vue field + // restores the page layout on any of them) + submitEvents: 0, } window.host = host + window.addEventListener('submit', () => host.submitEvents++, { + capture: true, + }) // The file manager is closed without choosing a file ImageField.filemanagerController.open = () => { diff --git a/visual-editor/src/VisualEditor.tsx b/visual-editor/src/VisualEditor.tsx index de83d630..89ec1019 100644 --- a/visual-editor/src/VisualEditor.tsx +++ b/visual-editor/src/VisualEditor.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Action, @@ -14,6 +14,7 @@ import { createStore, Store, StoreProvider, usePartialStore } from 'src/store' import { indexify, stringifyFields } from 'src/functions/object' import { useClipboardPaste } from 'src/hooks/useClipboardPaste' import { fillDefaults } from 'src/functions/fields' +import { t } from 'src/functions/i18n' import { useStateDelayed } from 'src/hooks/useStateDelayed' import { BaseStyles } from 'src/components/BaseStyles' import { Translations as EN } from 'src/langs/en' @@ -35,9 +36,9 @@ import { const components: EditorComponentDefinitions = {} const templates: EditorComponentTemplate[] = [] const actions: Action[] = [] -const defaultDevices: Device[] = [ - { name: 'Mobile', width: 390, height: '100%', icon: 'mobile' }, - { name: 'Desktop', width: '100%', height: '100%', icon: 'desktop' }, +const defaultDevices = (): Device[] => [ + { name: t('deviceMobile'), width: 390, height: '100%', icon: 'mobile' }, + { name: t('deviceDesktop'), width: '100%', height: '100%', icon: 'desktop' }, ] export { PreviewWrapper } from './elements/PreviewWrapper' @@ -60,7 +61,7 @@ export class VisualEditor { } = {} ) { VisualEditor.i18n = options.lang ?? EN - VisualEditor.devices = options.devices ?? defaultDevices + VisualEditor.devices = options.devices ?? defaultDevices() VisualEditor.postMessagePreview = options.postMessagePreview ?? false } @@ -291,6 +292,7 @@ export function VisualEditorComponent({ // We want to avoid bubbling change & close event const div = useRef(null) useStopPropagation(div, ['change', 'close']) + usePreventImplicitSubmit(div) if (!visible) { return @@ -310,6 +312,26 @@ export function VisualEditorComponent({ ) } +/** + * The editor usually sits inside the form of the host page: Enter in one of + * its inputs must not submit that form + */ +function usePreventImplicitSubmit(ref: React.RefObject) { + useEffect(() => { + const element = ref.current + if (!element) { + return + } + const listener = (e: KeyboardEvent) => { + if (e.key === 'Enter' && e.target instanceof HTMLInputElement) { + e.preventDefault() + } + } + element.addEventListener('keydown', listener) + return () => element.removeEventListener('keydown', listener) + }) +} + function HiddenTextarea({ name }: { name: string }) { const doNothing = () => null // React wants handler :( const { data } = usePartialStore('data') diff --git a/visual-editor/src/components/Blocs/BlocSelector.tsx b/visual-editor/src/components/Blocs/BlocSelector.tsx index 20028dfe..877d2faf 100644 --- a/visual-editor/src/components/Blocs/BlocSelector.tsx +++ b/visual-editor/src/components/Blocs/BlocSelector.tsx @@ -11,8 +11,6 @@ import { BlocSelectorSearch } from 'src/components/Blocs/BlocSelectorSearch' import { BlocSelectorGrid } from 'src/components/Blocs/BlocSelectorGrid' import { t } from 'src/functions/i18n' -const ALL_TAB = 'Tous les blocs' - type BlocSelectorProps = { iconsUrl: string } @@ -26,9 +24,10 @@ export function BlocSelector({ iconsUrl }: BlocSelectorProps) { 'hiddenCategories' ) const addBlock = useAddBlock() + const allTab = t('allBlocks') const categories = useMemo(() => { return [ - ALL_TAB, + allTab, ...Object.values(definitions) .filter((d) => d.category) .filter((d) => !hiddenCategories.includes(d.category ?? '')) @@ -37,7 +36,7 @@ export function BlocSelector({ iconsUrl }: BlocSelectorProps) { [] as string[] ), ] - }, [definitions]) + }, [definitions, allTab]) useEffect(() => { setSearch('') @@ -66,7 +65,14 @@ export function BlocSelector({ iconsUrl }: BlocSelectorProps) { (key) => !hiddenCategories.includes(definitions[key]!.category ?? '') ) - .filter(searchDefinition(search ?? '', category, definitions)) + .filter( + searchDefinition( + search ?? '', + category === allTab, + category, + definitions + ) + ) .map((key) => ( { - const categoryFilter = - category === ALL_TAB ? true : definitions[key]!.category === category + const categoryFilter = allCategories + ? true + : definitions[key]!.category === category const searchFilter = search === '' ? true diff --git a/visual-editor/src/components/Editor/TiptapEditor/TiptapColorPicker.tsx b/visual-editor/src/components/Editor/TiptapEditor/TiptapColorPicker.tsx index ff0d3a4a..c716c06b 100644 --- a/visual-editor/src/components/Editor/TiptapEditor/TiptapColorPicker.tsx +++ b/visual-editor/src/components/Editor/TiptapEditor/TiptapColorPicker.tsx @@ -6,6 +6,7 @@ import styled from '@emotion/styled' import { prevent } from 'src/functions/functions' import { useToggle } from 'src/hooks/useToggle' import { Styles } from 'src/components/ui' +import { t } from 'src/functions/i18n' type Props = { editor: Editor @@ -44,7 +45,7 @@ export function TiptapColorPicker({ editor, colors }: Props) { return (
- @@ -188,7 +196,7 @@ function ToolbarButtons({ editor.chain().focus().toggleBulletList().run() )} active={editor.isActive('bulletList')} - title="Unordered List" + title={t('bulletList')} > @@ -199,7 +207,7 @@ function ToolbarButtons({ editor.chain().focus().toggleBlockquote().run() )} active={editor.isActive('blockquote')} - title="Blockquote" + title={t('blockquote')} > @@ -210,35 +218,35 @@ function ToolbarButtons({ @@ -248,11 +256,11 @@ function ToolbarButtons({ - diff --git a/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarAlign.tsx b/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarAlign.tsx index 95543e57..598fc033 100644 --- a/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarAlign.tsx +++ b/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarAlign.tsx @@ -4,7 +4,7 @@ import { Editor } from '@tiptap/core' import { TiptapToolbarButton as Button } from './TiptapToolbarButton' import { TiptapDropdown as Dropdown } from './TiptapDropdown' import { prevent } from 'src/functions/functions' -import { capitalize } from 'src/functions/string' +import { alignmentLabel } from 'src/functions/i18n' export const TiptapToolbarAlign: FunctionComponent<{ editor: Editor }> = ({ editor, @@ -19,7 +19,7 @@ export const TiptapToolbarAlign: FunctionComponent<{ editor: Editor }> = ({ return ( - {alignments @@ -31,7 +31,7 @@ export const TiptapToolbarAlign: FunctionComponent<{ editor: Editor }> = ({ editor.chain().focus().setTextAlign(align).run() )} css={{ height: 30 }} - title={capitalize(align)} + title={alignmentLabel(align)} > diff --git a/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarFormats.tsx b/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarFormats.tsx index 12af0b86..f81ac79f 100644 --- a/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarFormats.tsx +++ b/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarFormats.tsx @@ -4,23 +4,25 @@ import { prevent } from 'src/functions/functions' import { TiptapDropdown as Dropdown } from './TiptapDropdown' import { IconCode, IconSubscript, IconSuperscript } from './TiptapIcons' import { TiptapToolbarButton as Button } from './TiptapToolbarButton' +import { t } from 'src/functions/i18n' +import type { TranslationKey } from 'src/types' type FormatDefinition = { name: 'superscript' | 'subscript' | 'code' - title: string + title: TranslationKey Icon: FunctionComponent<{ size: number }> } const superscript: FormatDefinition = { name: 'superscript', - title: 'Superscript', + title: 'superscript', Icon: IconSuperscript, } const formats: FormatDefinition[] = [ superscript, - { name: 'subscript', title: 'Subscript', Icon: IconSubscript }, - { name: 'code', title: 'Code', Icon: IconCode }, + { name: 'subscript', title: 'subscript', Icon: IconSubscript }, + { name: 'code', title: 'code', Icon: IconCode }, ] /** @@ -44,7 +46,7 @@ export const TiptapToolbarFormats: FunctionComponent<{ editor: Editor }> = ({ @@ -56,7 +58,7 @@ export const TiptapToolbarFormats: FunctionComponent<{ editor: Editor }> = ({ active={editor.isActive(format.name)} onClick={toggle(format)} css={{ height: 30 }} - title={format.title} + title={t(format.title)} > diff --git a/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarHeadings.tsx b/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarHeadings.tsx index 969ae3ea..59a921d1 100644 --- a/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarHeadings.tsx +++ b/visual-editor/src/components/Editor/TiptapEditor/TiptapToolbarHeadings.tsx @@ -4,6 +4,7 @@ import { Editor } from '@tiptap/core' import { TiptapToolbarButton as Button } from './TiptapToolbarButton' import { TiptapDropdown as Dropdown } from './TiptapDropdown' import { prevent } from 'src/functions/functions' +import { t } from 'src/functions/i18n' type Level = 1 | 2 | 3 | 4 | 5 | 6 @@ -30,7 +31,11 @@ export const TiptapToolbarHeadings: FunctionComponent<{ editor: Editor }> = ({ return ( - {levels.map((level) => ( @@ -39,6 +44,7 @@ export const TiptapToolbarHeadings: FunctionComponent<{ editor: Editor }> = ({ active={level === currentLevel} onClick={clickHandler(level)} css={{ height: 30 }} + title={`${t('heading')} ${level}`} > diff --git a/visual-editor/src/components/Preview/Preview.tsx b/visual-editor/src/components/Preview/Preview.tsx index 910ff300..bc32d916 100644 --- a/visual-editor/src/components/Preview/Preview.tsx +++ b/visual-editor/src/components/Preview/Preview.tsx @@ -1,7 +1,6 @@ import { EditorComponentData } from 'src/types' -import React, { useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { useAsyncEffect } from 'src/hooks/useAsyncEffect' import { useWindowSize } from 'react-use' import { PHONE_HEIGHT } from 'src/constants' import { Spinner } from 'src/components/ui' @@ -28,33 +27,48 @@ export function Preview({ previewUrl }: PreviewProps) { const getData = useGetData() // Gère le chargement de la preview initiale - useAsyncEffect(async () => { - const data = getData() - // On génère le premier rendu de la page complète - const r = await fetch(previewUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(data), - }) - if (!r.ok) { - return - } + useEffect(() => { + // The editor can be closed before the preview is loaded + const controller = new AbortController() + const load = async () => { + const data = getData() + // On génère le premier rendu de la page complète + const r = await fetch(previewUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(data), + signal: controller.signal, + }) + if (!r.ok) { + return + } + const html = await r.text() + const iframeDocument = iframe.current?.contentDocument + if (controller.signal.aborted || !iframeDocument) { + return + } - // On écrit le contenu dans l'iframe - const iframeDocument = iframe.current!.contentDocument! - iframeDocument.open() - iframeDocument.write(await r.text()) - iframeDocument.close() - const root = iframeDocument.querySelector('#ve-components') as HTMLElement - initialHTML.current = Array.from(root.children).reduce( - (acc, v, k) => ({ ...acc, [data[k]!._id]: v.outerHTML }), - {} - ) - root.innerHTML = '' - setIframeRoot(root) + // On écrit le contenu dans l'iframe + iframeDocument.open() + iframeDocument.write(html) + iframeDocument.close() + const root = iframeDocument.querySelector('#ve-components') as HTMLElement + initialHTML.current = Array.from(root.children).reduce( + (acc, v, k) => ({ ...acc, [data[k]!._id]: v.outerHTML }), + {} + ) + root.innerHTML = '' + setIframeRoot(root) + } + load().catch((e) => { + if (!controller.signal.aborted) { + throw e + } + }) + return () => controller.abort() }, []) return ( diff --git a/visual-editor/src/components/Preview/PreviewAddButton.tsx b/visual-editor/src/components/Preview/PreviewAddButton.tsx index 26137cf2..60958c40 100644 --- a/visual-editor/src/components/Preview/PreviewAddButton.tsx +++ b/visual-editor/src/components/Preview/PreviewAddButton.tsx @@ -1,20 +1,17 @@ import { Button, IconCirclePlus } from 'src/components/ui' import React, { SyntheticEvent } from 'react' import styled from '@emotion/styled' +import { t } from 'src/functions/i18n' type PreviewAddButtonProps = { onClick?: (e: SyntheticEvent) => void } export function PreviewAddButton({ onClick }: PreviewAddButtonProps) { - return ( - ) diff --git a/visual-editor/src/components/Preview/PreviewAddFloating.tsx b/visual-editor/src/components/Preview/PreviewAddFloating.tsx index 335f2e0e..56f082a6 100644 --- a/visual-editor/src/components/Preview/PreviewAddFloating.tsx +++ b/visual-editor/src/components/Preview/PreviewAddFloating.tsx @@ -1,6 +1,7 @@ import React, { CSSProperties, SyntheticEvent } from 'react' import styled from '@emotion/styled' import { UnstyledButton } from 'src/components/ui' +import { t } from 'src/functions/i18n' type PreviewAddFloatingProps = { onClick?: (e: SyntheticEvent) => void @@ -13,7 +14,7 @@ export function PreviewAddFloating({ }: PreviewAddFloatingProps) { return ( ) } @@ -21,12 +22,13 @@ export function PreviewAddFloating({ const Button = styled(UnstyledButton)({ position: 'absolute', top: 0, - left: 0, - right: 0, + // The clickable area stays in the middle third, away from the block name + // (left) and the move / delete buttons (right) that sit on the same edge + left: '33%', + right: '33%', marginTop: '-40px', marginBottom: '-40px', opacity: 0, - width: '100%', height: '80px', display: 'flex', alignItems: 'center', @@ -39,10 +41,12 @@ const Button = styled(UnstyledButton)({ '&::before': { content: "''", height: '2px', - width: '100%', + // The line still spans the whole block (the button is a third of it) + width: '300%', position: 'absolute', top: 'calc(50% - 1px)', - left: '0', + left: '-100%', + pointerEvents: 'none', zIndex: 101, backgroundColor: 'var(--ve-primary)', }, diff --git a/visual-editor/src/components/Preview/PreviewItemWrapper.tsx b/visual-editor/src/components/Preview/PreviewItemWrapper.tsx index 0880929a..1edd83cd 100644 --- a/visual-editor/src/components/Preview/PreviewItemWrapper.tsx +++ b/visual-editor/src/components/Preview/PreviewItemWrapper.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled' import { PreviewAddFloating } from 'src/components/Preview/PreviewAddFloating' import { IconDown, IconTrash, IconUp } from 'src/components/ui' import { prevent } from 'src/functions/functions' +import { t } from 'src/functions/i18n' type Props = { title?: string @@ -42,6 +43,7 @@ export const PreviewItemWrapper = forwardRef( onMove(-1))} + title={t('moveUp')} style={{ marginLeft: 'auto', }} @@ -50,6 +52,7 @@ export const PreviewItemWrapper = forwardRef( onMove(1))} + title={t('moveDown')} style={{ marginLeft: 'auto', }} @@ -58,6 +61,7 @@ export const PreviewItemWrapper = forwardRef( = { @@ -23,7 +23,7 @@ export function AlignmentButton({ type="radio" value={value as string} onChange={() => onChange(value)} - title={capitalize(value as string)} + title={alignmentLabel(value as string)} {...props} />
diff --git a/visual-editor/src/functions/i18n.ts b/visual-editor/src/functions/i18n.ts index 4f4f8db4..d7afd325 100644 --- a/visual-editor/src/functions/i18n.ts +++ b/visual-editor/src/functions/i18n.ts @@ -1,6 +1,28 @@ import type { TranslationKey } from 'src/types' import { VisualEditor } from 'src/VisualEditor' +import { Translations as EN } from 'src/langs/en' +/** + * Falls back to English when the host translation lacks a key + * (translations written for an older version) + */ export function t(key: TranslationKey): string { - return VisualEditor.i18n[key] + return VisualEditor.i18n[key] ?? EN[key] +} + +const alignmentKeys: Record = { + left: 'alignLeft', + center: 'alignCenter', + right: 'alignRight', + justify: 'alignJustify', + top: 'alignTop', + bottom: 'alignBottom', +} + +/** + * Label of an alignment value (left, center, right...) + */ +export function alignmentLabel(value: string): string { + const key = alignmentKeys[value] + return key ? t(key) : value } diff --git a/visual-editor/src/hooks/useAsyncEffect.ts b/visual-editor/src/hooks/useAsyncEffect.ts deleted file mode 100644 index 0c47324b..00000000 --- a/visual-editor/src/hooks/useAsyncEffect.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useEffect, useState } from 'react' - -export function useAsyncEffect(cb: Function, deps: unknown[]) { - useEffect(() => { - cb() - }, deps) -} diff --git a/visual-editor/src/langs/en.ts b/visual-editor/src/langs/en.ts index 2e2b1e1b..01e25566 100644 --- a/visual-editor/src/langs/en.ts +++ b/visual-editor/src/langs/en.ts @@ -19,4 +19,35 @@ export const Translations: Translation = { noContent: `You have no content yet`, useTemplate: 'Use a template', poweredBy: 'Powered by', + allBlocks: 'All blocks', + moveUp: 'Move up', + moveDown: 'Move down', + deviceMobile: 'Mobile', + deviceDesktop: 'Desktop', + alignLeft: 'Left', + alignCenter: 'Center', + alignRight: 'Right', + alignJustify: 'Justify', + alignTop: 'Top', + alignBottom: 'Bottom', + orderedList: 'Ordered list', + bulletList: 'Bullet list', + blockquote: 'Quote', + heading: 'Heading', + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + highlight: 'Highlight', + strike: 'Strikethrough', + superscript: 'Superscript', + subscript: 'Subscript', + code: 'Code', + color: 'Color', + noColor: 'No color', + link: 'Link', + clearFormat: 'Clear formatting', + linkNewTab: 'New tab', + linkNewTabTitle: 'Open the link in a new tab', + linkSubmit: 'Ok', + linkInvalid: 'Invalid URL, add "https://"', } diff --git a/visual-editor/src/langs/fr.ts b/visual-editor/src/langs/fr.ts index bc826a08..7f291e45 100644 --- a/visual-editor/src/langs/fr.ts +++ b/visual-editor/src/langs/fr.ts @@ -17,4 +17,35 @@ export const Translations = { poweredBy: 'Propulsé par', noContent: `Vous n'avez pas encore de contenu`, useTemplate: 'Utiliser un modèle', + allBlocks: 'Tous les blocs', + moveUp: 'Monter le bloc', + moveDown: 'Descendre le bloc', + deviceMobile: 'Mobile', + deviceDesktop: 'Ordinateur', + alignLeft: 'Gauche', + alignCenter: 'Centre', + alignRight: 'Droite', + alignJustify: 'Justifié', + alignTop: 'Haut', + alignBottom: 'Bas', + orderedList: 'Liste numérotée', + bulletList: 'Liste à puces', + blockquote: 'Citation', + heading: 'Titre', + bold: 'Gras', + italic: 'Italique', + underline: 'Souligné', + highlight: 'Surligné', + strike: 'Barré', + superscript: 'Exposant', + subscript: 'Indice', + code: 'Code', + color: 'Couleur', + noColor: 'Aucune couleur', + link: 'Lien', + clearFormat: 'Effacer la mise en forme', + linkNewTab: 'Nouvel onglet', + linkNewTabTitle: 'Ouvrir le lien dans un nouvel onglet', + linkSubmit: 'OK', + linkInvalid: 'Adresse invalide, ajoutez "https://"', } diff --git a/visual-editor/test/functions/i18n.test.ts b/visual-editor/test/functions/i18n.test.ts new file mode 100644 index 00000000..f326649c --- /dev/null +++ b/visual-editor/test/functions/i18n.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { Translations as EN } from 'src/langs/en' +import { Translations as FR } from 'src/langs/fr' +import { VisualEditor } from 'src/VisualEditor' +import { alignmentLabel, t } from 'src/functions/i18n' + +describe('translations', () => { + it('has the same keys in English and French', () => { + expect(Object.keys(FR).sort()).toEqual(Object.keys(EN).sort()) + }) + + it('uses English by default', () => { + new VisualEditor() + expect(t('linkNewTab')).toBe('New tab') + expect(VisualEditor.devices.map((d) => d.name)).toEqual([ + 'Mobile', + 'Desktop', + ]) + }) + + it('uses the language given to the editor', () => { + new VisualEditor({ lang: FR }) + expect(t('linkNewTab')).toBe('Nouvel onglet') + expect(alignmentLabel('center')).toBe('Centre') + expect(VisualEditor.devices.map((d) => d.name)).toEqual([ + 'Mobile', + 'Ordinateur', + ]) + }) + + it('falls back to English for a key missing in the host translation', () => { + const { linkNewTab, ...partial } = FR + new VisualEditor({ lang: partial as typeof FR }) + expect(t('linkNewTab')).toBe('New tab') + expect(t('save')).toBe('Sauvegarder') + }) +}) From 56d91b71fe321012b9158ffa08b03796ffdb403e Mon Sep 17 00:00:00 2001 From: Francois Date: Fri, 23 Oct 2026 14:48:59 +0200 Subject: [PATCH 2/2] bump: 0.2.2 --- docs/docs/changelog.mdx | 7 +++++++ docs/docs/translation.mdx | 2 ++ visual-editor/package.json | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/docs/changelog.mdx b/docs/docs/changelog.mdx index 2c8f7092..0581ae76 100644 --- a/docs/docs/changelog.mdx +++ b/docs/docs/changelog.mdx @@ -4,6 +4,13 @@ sidebar_position: 10 # Changelog +## 0.2.2 - 2026/10/23 + +- Fix: Enter in the link field of the text toolbar no longer submits the form around the editor (the link form is no longer a nested `
`), and Enter in an input of the editor never submits it +- Fix: closing the editor before the preview is loaded no longer throws an error +- Fix: the "add a component" zone of the preview stays in the middle of the block edge, the move and delete buttons and the block name can no longer open the component list +- Fix: the texts added in 0.2 (text toolbar tooltips, "New tab" link option, preview buttons, device names) and the "All blocks" tab go through the translations, French included; a translation given to the editor without the new keys falls back to English + ## 0.2.1 - 2026/10/09 - Fix: the `change` event carries the JSON of the page in `detail` again (removed in 0.2.0), and is emitted for every change made in the editor, including the preview arrows. It is not emitted when the host sets `value` diff --git a/docs/docs/translation.mdx b/docs/docs/translation.mdx index 8108c487..4b231849 100644 --- a/docs/docs/translation.mdx +++ b/docs/docs/translation.mdx @@ -15,3 +15,5 @@ const editor = new VisualEditor({ ``` You can see all the available keys in the [source code](https://github.com/ciklik/visual-editor/blob/main/visual-editor/src/langs/en.ts). + +A key missing from your translation falls back to the English text. diff --git a/visual-editor/package.json b/visual-editor/package.json index 4532730a..5a8594b0 100644 --- a/visual-editor/package.json +++ b/visual-editor/package.json @@ -1,6 +1,6 @@ { "name": "@boxraiser/visual-editor", - "version": "0.2.1", + "version": "0.2.2", "description": "Block based visual editor, as a custom element", "license": "MIT", "type": "module",