Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions docs/docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<form>`), 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`
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
97 changes: 97 additions & 0 deletions visual-editor/cypress/e2e/compat.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<html><body><main id="ve-components"></main></body></html>',
})
}).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)
})
})
})
})
2 changes: 1 addition & 1 deletion visual-editor/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions visual-editor/server/compat.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
32 changes: 27 additions & 5 deletions visual-editor/src/VisualEditor.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
}

Expand Down Expand Up @@ -291,6 +292,7 @@ export function VisualEditorComponent({
// We want to avoid bubbling change & close event
const div = useRef<HTMLDivElement>(null)
useStopPropagation(div, ['change', 'close'])
usePreventImplicitSubmit(div)

if (!visible) {
return <HiddenTextarea name={name} />
Expand All @@ -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<HTMLElement>) {
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')
Expand Down
22 changes: 15 additions & 7 deletions visual-editor/src/components/Blocs/BlocSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 ?? ''))
Expand All @@ -37,7 +36,7 @@ export function BlocSelector({ iconsUrl }: BlocSelectorProps) {
[] as string[]
),
]
}, [definitions])
}, [definitions, allTab])

useEffect(() => {
setSearch('')
Expand Down Expand Up @@ -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) => (
<BlocSelectorItem
key={key}
Expand All @@ -85,12 +91,14 @@ export function BlocSelector({ iconsUrl }: BlocSelectorProps) {

function searchDefinition(
search: string,
allCategories: boolean,
category: string,
definitions: EditorComponentDefinitions
) {
return (key: string) => {
const categoryFilter =
category === ALL_TAB ? true : definitions[key]!.category === category
const categoryFilter = allCategories
? true
: definitions[key]!.category === category
const searchFilter =
search === ''
? true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,7 +45,7 @@ export function TiptapColorPicker({ editor, colors }: Props) {

return (
<div css={{ position: 'relative' }}>
<Button onClick={prevent(toggleExpanded)} title="Color">
<Button onClick={prevent(toggleExpanded)} title={t('color')}>
<svg
width={16}
height={16}
Expand Down Expand Up @@ -120,7 +121,7 @@ export function Palette({
const changeHandler = (color: string | null) => prevent(() => onChange(color))
return (
<PaletteWrapper size={colors.length + 1}>
<PaletteItemReset onClick={changeHandler(null)} title="No color" />
<PaletteItemReset onClick={changeHandler(null)} title={t('noColor')} />
{colors.map((color) => (
<PaletteItem
key={color}
Expand Down
Loading
Loading