Translate pages, areas, menus, forms and posts field by field instead of rebuilding them

A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-09 16:56:52 +02:00
1 parent c7705b7083
commit e15b2517b1
51 files changed
+3045 -219

No files matched your search

+257 -12
View File
@@ -13,6 +13,7 @@ import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PR
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
import { renderIconField } from './icons.js';
import { entries as translationEntries, setText, confirmText, setPageValue, slugify, plainText } from './translate.js';
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
@@ -32,6 +33,9 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
// same factor, so it is kept here instead of being recomputed from the transform string.
let previewScale = 1, overlay = null, pendingInsert = null;
const collapsed = new Set();
// Übersetzen (ADR 111): welche Sprachen es gibt, und welche gerade bearbeitet wird. null ist die
// Hauptsprache - dann ist der Editor genau der von vorher.
let languageInfo = null, language = null, translateFilter = 'all';
let renderedProperties = null;
let revealAfterPreview = null;
let previewPlayback = false, stopPlayback = () => {};
@@ -119,7 +123,7 @@ function controls() {
$('#undo').disabled = !ready || busy || !history.past.length;
$('#redo').disabled = !ready || busy || !history.future.length;
const current = ready ? locate(history.document.blocks, selected) : null;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy || Boolean(language);
if (current) {
$('#move-up').disabled ||= current.index === 0;
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
@@ -142,6 +146,8 @@ function controls() {
$('[data-panel="design"]').disabled ||= !current;
$('#open-media-library').disabled = !ready || busy;
$('#preview-motion').disabled = !ready || busy;
$('#language-switch').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#translate-panel').querySelectorAll('input,textarea,button').forEach(input => { input.disabled = busy; });
}
function renderTree() {
@@ -203,6 +209,7 @@ function choose(id, scroll = true) {
history.mergeKey = null;
syncView();
renderTree(); renderProperties(); controls(); highlight(scroll);
if (language && selected) focusTranslation(selected);
}
/**
@@ -210,6 +217,7 @@ function choose(id, scroll = true) {
* the exception: there the selection only moves the highlight, editing is one more step.
*/
function syncView() {
if (language) return;
if (selected && view !== 'structure') setView('element');
else if (!selected && view === 'element') setView('elements');
}
@@ -594,6 +602,12 @@ function renderContent(block, panel) {
if (block.type === 'legal') {
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
}
if (block.type === 'language') {
segmented('Anzeige', 'display', block.props.display === 'name' ? 'name' : 'code', [['code', 'Kürzel (DE · EN)'], ['name', 'Namen (Deutsch · English)']], 27);
const help = document.createElement('p'); help.className = 'field-help';
help.textContent = 'Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist. Die Sprachen stehen unter Einstellungen → Sprachen. Auf einer Seite, die es nur in einer Sprache gibt, bleibt die Sprachwahl leer. Am besten in den Kopfbereich setzen.';
panel.append(help);
}
if (block.type === 'postnav') {
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
@@ -978,7 +992,7 @@ async function updatePreview() {
const sequence = previewSequence;
const controller = new AbortController(); previewController = controller;
try {
const html = await request('/preview', { document: history.document }, controller.signal);
const html = await request('/preview', { document: history.document, language }, controller.signal);
if (sequence !== previewSequence) return;
const frame = $('#preview');
const top = frame.contentWindow?.scrollY || 0;
@@ -1019,7 +1033,7 @@ async function updatePreview() {
menuSummary.parentElement.open = !menuSummary.parentElement.open;
}
const insert = event.target.closest('[data-editor-target]');
if (insert && !busy && history) {
if (insert && !busy && history && !language) {
stopInlineEdit();
const id = insert.dataset.editorTarget;
if (!locate(history.document.blocks, id)) return;
@@ -1044,12 +1058,13 @@ async function updatePreview() {
const element = event.target.closest('[id]');
if (!element) return;
const block = locate(history.document.blocks, element.id)?.block;
if (language) return;
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
// Right click: the element's actions where the pointer is, as in Flatsome.
doc.addEventListener('contextmenu', event => {
const element = event.target.closest('[id]');
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit || language) return;
event.preventDefault();
choose(element.id, false);
openContextMenu(element.id, event.clientX, event.clientY);
@@ -1065,7 +1080,8 @@ async function updatePreview() {
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
const finish = () => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
decorateContainers(doc, history.document);
// Beim Übersetzen gibt es nichts einzufügen: der Aufbau gehört der Hauptsprache.
if (!language) decorateContainers(doc, history.document);
overlay?.attach();
styleReady.then(loaded => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
@@ -1075,7 +1091,9 @@ async function updatePreview() {
frame.style.visibility = '';
showMotionStatus();
playPreview();
$('#preview-status').textContent = loaded
$('#preview-status').textContent = loaded && language
? `Entwurf auf ${languageName(language)} · Klicken springt zum Text in der Übersetzungsliste. Leere Felder zeigen den Text der Hauptsprache.`
: loaded
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
});
@@ -1114,7 +1132,226 @@ function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
syncView();
renderTree(); renderProperties(); controls(); schedulePreview();
renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
}
// ---------------------------------------------------------------------------------------------
// Übersetzen (ADR 111).
//
// Dieselbe Seite, dieselbe Vorschau - nur die Seitenleiste zeigt statt Werkzeugkasten und
// Eigenschaften die Liste der Texte: Original links darüber, Übersetzung darunter. Aufbau und
// Gestaltung bleiben in der Hauptsprache; hier lässt sich nichts verschieben oder löschen.
// Jede Eingabe ist ein gewöhnlicher Schritt im Verlauf, also gelten Undo, Speichern und
// Veröffentlichen wie immer und für alle Sprachen zugleich.
// ---------------------------------------------------------------------------------------------
const languageName = code => languageInfo?.extra.find(item => item.code === code)?.name ?? languageInfo?.main.name ?? code;
function renderLanguageSwitch() {
const holder = $('#language-switch');
const extra = languageInfo?.extra ?? [];
holder.hidden = !extra.length;
$('#publish-languages').hidden = !extra.length;
holder.replaceChildren();
if (!extra.length) return;
for (const item of [languageInfo.main, ...extra]) {
const button = document.createElement('button'); button.type = 'button';
button.textContent = item.code.toUpperCase();
button.title = item === languageInfo.main ? `${item.name} – Hauptsprache: Aufbau, Gestaltung und Texte` : `${item.name} – nur die Texte übersetzen`;
// Der Name beginnt mit dem sichtbaren Kürzel, damit auch Sprachsteuerung den Knopf findet.
button.setAttribute('aria-label', item === languageInfo.main ? `${item.code.toUpperCase()} – ${item.name} (Hauptsprache) bearbeiten` : `${item.code.toUpperCase()} – ${item.name} übersetzen`);
const active = (language ?? languageInfo.main.code) === item.code;
button.setAttribute('aria-pressed', String(active));
button.addEventListener('click', () => setLanguage(item === languageInfo.main ? null : item.code));
holder.append(button);
}
}
function setLanguage(code) {
if (busy || !history || code === language) return;
stopInlineEdit(); closeContextMenu(); setPendingInsert(null);
language = code;
if (language) root.dataset.language = language; else delete root.dataset.language;
selected = null; history.mergeKey = null; translateFilter = 'all';
setView(language ? 'translate' : 'elements');
renderLanguageSwitch(); renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
if (language) $('#translate-panel').querySelector('h2')?.focus();
}
/** Springt in der Liste zu den Texten des gewählten Blocks oder des ersten Blocks darin. */
function focusTranslation(id) {
const panel = $('#translate-panel');
const ids = [];
const collect = block => { ids.push(block.id); (block.children ?? []).forEach(collect); };
const block = locate(history.document.blocks, id)?.block;
if (!block) return;
collect(block);
const group = ids.map(item => panel.querySelector(`.translate-group[data-block-id="${CSS.escape(item)}"]`)).find(Boolean);
if (!group) return;
if (group.hidden) { translateFilter = 'all'; renderTranslations(); return focusTranslation(id); }
panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current'));
group.classList.add('is-current');
const body = $('.sidebar-body');
body.scrollTop += group.getBoundingClientRect().top - body.getBoundingClientRect().top - 12;
group.querySelector('textarea,input,[contenteditable="true"]')?.focus({ preventScroll: true });
}
const STATE_TEXT = { missing: 'Fehlt', stale: 'Original geändert', done: 'Übersetzt' };
function renderTranslations() {
const panel = $('#translate-panel');
if (!language || !history) { panel.replaceChildren(); return; }
const scroll = $('.sidebar-body').scrollTop;
const info = languageInfo.extra.find(item => item.code === language);
const doc = history.document;
const translation = doc.translations?.[language] ?? {};
// Ein Kopf- oder Fußbereich hat weder Adresse noch Seitentitel - nur Texte in Blöcken.
const list = translationEntries(doc, language).filter(entry => !languageInfo.area || entry.blockId);
const count = { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
panel.replaceChildren();
const head = el('div', 'translate-head');
const title = el('h2', '', `Übersetzung: ${info.name}`); title.tabIndex = -1;
const meter = el('progress'); meter.max = Math.max(1, count.total); meter.value = count.done; meter.setAttribute('aria-label', 'Übersetzt');
const summary = el('p', 'translate-summary', `${count.done} von ${count.total} Texten übersetzt${count.stale ? ` · ${count.stale} mit geändertem Original` : ''}`);
summary.setAttribute('role', 'status');
const hint = el('p', 'field-help', `Aufbau, Bilder und Gestaltung bearbeitest du in ${languageInfo.main.name}. Hier stehen nur die Texte. Ein leeres Feld zeigt auf der Website den Text in ${languageInfo.main.name}.`);
const filter = el('div', 'segmented translate-filter'); filter.setAttribute('role', 'group'); filter.setAttribute('aria-label', 'Texte filtern');
filter.style.gridTemplateColumns = 'repeat(3, 1fr)';
for (const [value, text] of [['all', 'Alle'], ['missing', 'Fehlend'], ['stale', 'Geändert']]) {
const option = el('button', '', text); option.type = 'button'; option.setAttribute('aria-pressed', String(translateFilter === value));
option.addEventListener('click', () => { translateFilter = value; renderTranslations(); });
filter.append(option);
}
head.append(title, meter, summary, hint, filter);
panel.append(head);
// Adresse und Suchtitel: gehören nur zur Übersetzung, ein Original gibt es dafür nicht.
const page = el('section', 'translate-group translate-page'); page.dataset.blockId = '';
if (languageInfo.area) page.hidden = true;
page.append(el('h3', '', 'Seite'));
const published = languageInfo.published?.[language];
if (languageInfo.home) {
page.append(el('p', 'field-help', `Startseite – erscheint unter /${language}.`));
} else {
const label = el('label', '', 'Adresse'); label.htmlFor = 'translate-slug';
const wrap = el('div', 'translate-slug');
const prefix = el('span', 'translate-prefix', `/${language}/…/`); prefix.setAttribute('aria-hidden', 'true');
const input = el('input'); input.id = 'translate-slug'; input.maxLength = 80; input.value = translation.slug ?? '';
input.placeholder = languageInfo.slug; input.spellcheck = false; input.autocomplete = 'off';
input.pattern = '[a-z0-9]+(-[a-z0-9]+)*';
const help = el('p', 'field-help', `Kleinbuchstaben, Ziffern, Bindestriche. Leer: wie in ${languageInfo.main.name} (${languageInfo.slug}).`); help.id = 'translate-slug-help';
input.setAttribute('aria-describedby', help.id);
input.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'slug', input.value), 'tr-slug'); controls(); });
const suggest = el('button', 'text-button', 'Aus dem übersetzten Titel'); suggest.type = 'button';
suggest.addEventListener('click', () => {
const proposal = slugify(history.document.translations?.[language]?.title ?? '');
if (!proposal) { help.textContent = 'Erst den Seitentitel übersetzen, dann lässt sich die Adresse daraus bilden.'; return; }
input.value = proposal; history.change(content => setPageValue(content, language, 'slug', proposal)); controls();
});
wrap.append(prefix, input);
page.append(label, wrap, help, suggest);
}
const seoLabel = el('label', '', 'Titel für Suchmaschinen'); seoLabel.htmlFor = 'translate-seo';
const seo = el('input'); seo.id = 'translate-seo'; seo.maxLength = 160; seo.value = translation.seoTitle ?? ''; seo.lang = language;
seo.placeholder = 'Leer: der übersetzte Seitentitel';
seo.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'seoTitle', seo.value), 'tr-seo'); controls(); });
page.append(seoLabel, seo);
page.append(el('p', 'field-help', published
? `Veröffentlicht unter /${language}${languageInfo.home ? '' : `/${published}`}. Änderungen gehen mit „Veröffentlichen“ online.`
: `Noch nicht auf der Website. Die Seite erscheint auf ${info.name}, sobald ein Text übersetzt und die Seite veröffentlicht ist.`));
panel.append(page);
// Die Texte, gruppiert nach Block in Seitenreihenfolge; Seitentitel und Beschreibung vorn.
let group = null, groupId;
for (const entry of list) {
const id = entry.blockId ?? '__page';
if (id !== groupId) {
groupId = id;
group = el('section', 'translate-group'); group.dataset.blockId = entry.blockId ?? '';
const heading = el('h3');
if (entry.blockId) {
const jump = el('button', 'translate-jump', describe(entry.block)); jump.type = 'button';
jump.title = 'In der Vorschau zeigen';
const current = group;
jump.addEventListener('click', () => { selected = entry.blockId; highlight(true); panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); current.classList.add('is-current'); });
heading.append(jump);
} else heading.textContent = 'Seitentitel und Beschreibung';
group.append(heading);
panel.append(group);
}
group.append(translationField(entry, info));
}
for (const section of panel.querySelectorAll('.translate-group:not(.translate-page)')) {
const fields = [...section.querySelectorAll('.translate-field')];
for (const field of fields) field.hidden = translateFilter !== 'all' && field.dataset.state !== translateFilter;
section.hidden = fields.every(field => field.hidden);
}
if (!list.length) panel.append(el('p', 'panel-hint', languageInfo.area ? 'In diesem Bereich gibt es keine Texte zum Übersetzen. Menübeschriftungen übersetzt du unter Menüs.' : 'Auf dieser Seite gibt es noch keine Texte zum Übersetzen.'));
else if (languageInfo.area) head.append(el('p', 'field-help', 'Gespeicherte Übersetzungen eines Bereichs gelten sofort auf allen Seiten dieser Sprache. Menübeschriftungen übersetzt du unter Menüs.'));
else if (translateFilter !== 'all' && !panel.querySelector('.translate-field:not([hidden])')) panel.append(el('p', 'panel-hint', translateFilter === 'missing' ? 'Alles übersetzt.' : 'Kein Original wurde seit der Übersetzung geändert.'));
if (selected) panel.querySelector(`.translate-group[data-block-id="${CSS.escape(selected)}"]`)?.classList.add('is-current');
$('.sidebar-body').scrollTop = scroll;
controls();
}
/** Kurzname eines Blocks für die Liste: Art und der Anfang des Originals. */
function describe(block) {
const text = plainText(block.props?.text ?? block.props?.title ?? block.props?.alt ?? block.props?.label ?? '');
return text ? `${blockLabel(block)} · ${text.length > 40 ? `${text.slice(0, 40)} …` : text}` : blockLabel(block);
}
function translationField(entry, info) {
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
const row = el('div', 'translate-field'); row.dataset.state = entry.state; row.dataset.key = entry.key;
const id = `tr-${entry.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
const label = el('label', '', entry.label); label.htmlFor = id;
const badge = el('span', 'translate-state', STATE_TEXT[entry.state]);
const top = el('div', 'translate-field-head'); top.append(label, badge);
const original = el('p', 'translate-original'); original.lang = languageInfo.main.code;
original.textContent = entry.field.rich ? plainText(entry.original) : entry.original;
const originalId = `${id}-original`; original.id = originalId;
const update = value => {
history.change(content => setText(content, language, entry.key, value), `tr-${entry.key}`);
const state = value.trim() === '' ? 'missing' : 'done';
row.dataset.state = state; badge.textContent = STATE_TEXT[state];
controls(); schedulePreview();
};
row.append(top, original);
let input;
const rich = entry.field.rich === 'text' ? history.document.schemaVersion >= 12 : entry.field.rich === 'heading' ? history.document.schemaVersion >= 17 : false;
if (rich) {
const editor = createRichText(entry.value, markup => update(plainText(markup) === '' ? '' : markup), { label: `${entry.label} auf ${info.name}` });
editor.element.lang = language;
if (info.rtl) editor.element.dir = 'rtl';
editor.element.querySelector('[contenteditable]')?.setAttribute('aria-describedby', originalId);
// Das Label zeigt auf die Fläche, die man beschreibt.
const surface = editor.element.querySelector('[contenteditable]'); if (surface) { surface.id = id; label.addEventListener('click', () => surface.focus()); }
row.append(editor.element);
} else {
input = el(entry.field.multiline ? 'textarea' : 'input');
input.id = id; input.value = entry.value; input.maxLength = entry.field.max; input.lang = language;
if (info.rtl) input.dir = 'rtl';
if (entry.field.multiline) input.rows = 3;
input.setAttribute('aria-describedby', originalId);
input.addEventListener('input', () => update(input.value));
row.append(input);
}
const actions = el('div', 'translate-actions');
if (entry.state === 'missing') {
const copy = el('button', 'text-button', 'Original übernehmen'); copy.type = 'button';
copy.title = 'Den Text der Hauptsprache als Ausgangspunkt einsetzen';
copy.addEventListener('click', () => { history.change(content => setText(content, language, entry.key, entry.original)); changed(); });
actions.append(copy);
}
if (entry.state === 'stale') {
const ok = el('button', 'text-button', 'Übersetzung passt'); ok.type = 'button';
ok.title = 'Das Original wurde geändert, die Übersetzung stimmt trotzdem';
ok.addEventListener('click', () => { history.change(content => confirmText(content, language, entry.key)); changed(); });
actions.append(ok);
}
if (actions.childElementCount) row.append(actions);
return row;
}
async function loadDesignSchema() {
@@ -1152,10 +1389,15 @@ async function load() {
// der Server schon die Vorschau ab (siehe repairSchema).
history = new EditorHistory(repairSchema(data.document));
({ version, revisionId, publishedRevisionId, csrfToken } = data);
languageInfo = data.languages ?? null;
if (language && !languageInfo?.extra.some(item => item.code === language)) { language = null; delete root.dataset.language; }
renderLanguageSwitch();
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
// bekommt dafür eine eigene Gruppe.
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
if (language) setView('translate');
changed(); resizePreview();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
}
@@ -1167,13 +1409,15 @@ async function save(publish = false) {
try {
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
({ version, revisionId, publishedRevisionId, csrfToken } = data);
if (data.languages) languageInfo = data.languages;
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
history.adopt(data.document);
history.markSaved(data.document);
renderTree(); renderProperties(); schedulePreview();
renderTree(); renderProperties(); renderTranslations(); schedulePreview();
} else history.markSaved(snapshot);
if (publish && language) renderTranslations();
notify();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
@@ -1514,12 +1758,13 @@ overlay = createCanvasOverlay({
getDocument: () => history.document,
getSelected: () => selected,
onAction: (action, id) => {
if (!id || busy || !history) return;
if (!id || busy || !history || language) return;
if (action === 'up') actions.move(id, -1);
else if (action === 'down') actions.move(id, 1);
else actions[action]?.(id);
},
onInsert: (id, where) => {
if (language) return;
const item = locate(history.document.blocks, id);
if (!item) return;
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
@@ -1527,12 +1772,12 @@ overlay = createCanvasOverlay({
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
// the whole row for the current device, one undo step per drag.
onResize: (rowId, value) => {
if (busy || !history) return;
if (busy || !history || language) return;
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
changed();
},
onDrop: (payload, plan) => {
if (busy || !history) return;
if (busy || !history || language) return;
if (payload.kind === 'add') {
const id = history.change(doc => payload.columns
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)