diff --git a/VERSION b/VERSION index 661e7ae..10c0880 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.3 +1.7.4 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7fab50c..784a918 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,6 +12,8 @@ Ein Hintergrund wird mit `style..backgroundImage: "none"` ausdrücklich **Migration:** Dokumente v1–19 bleiben lesbar und werden nicht beim Laden umgeschrieben. Das Editor-Modell hebt die Version erst beim Setzen eines neuen Block-Linkwerts oder eines ausdrücklichen Hintergrund-„none“ auf v20 an, einschließlich der bisherigen Schema-Upgrades. Die Validierung verlangt für diese Werte v20. Keine Datenbankmigration. Rückweg auf ältere Software: vor Bearbeitung mit v20 angelegte Revision/Export oder die Sicherung vor dem Update wiederherstellen; ältere Validatoren können v20 nicht speichern. Menüs benötigen ebenfalls die Sicherung, falls neue Tab-Einstellungen auf älterem Code erhalten bleiben sollen. Kein stilles Herabsetzen der Dokumentversion. +**Korrektur 1.7.4:** Native Inhalts-Auswahlfelder verarbeiten `input` und `change`, identische aufeinanderfolgende Werte werden nur einmal übernommen. Jede Auswahl ist ein eigener Undo-Schritt. Die Bild-Klickaktion ersetzt beim Wechsel nicht mehr den ganzen Feinschliff: Linkziel und Tab-Häkchen werden in einer bestehenden Gruppe ein-/ausgeblendet. Das erhält Dropdown, Fokus und eingegebene Adresse. Ein Test mit ausschließlich `change` konnte das fehlende URL-Feld vor der Korrektur reproduzieren; native Auswahl und Speicherung werden zusätzlich geprüft. Keine Änderung des Datenvertrags. + ## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026) Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style..width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript. diff --git a/docs/STATUS.md b/docs/STATUS.md index ab7685c..646ecb5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ # Projektstatus +## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026) + +**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4. + +**Geprüft:** Der ergänzte Browserfall mit ausschließlich `change` scheiterte vor dem Fix am fehlenden URL-Feld (`var/image-link-before.log`). Danach `npm run test:link-media` mit sechs Prüfgruppen bestanden: erstmals verlinktes Bild, natives Dropdown mit Mausöffnung/Tastaturwahl, Tastaturwechsel, stabiler DOM-Knoten/Fokus, Nur-change-Ereignis, URL-Erhalt, Vergrößern/Nichts tun, Undo/Redo, Speichern/Laden und veröffentlichte Zieladresse. Bestehende Bild-, Hintergrund-, Textlink- und Menüprüfungen ebenfalls erfolgreich. Desktop-/Mobil-Editor und öffentliche Seite ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). 32 JavaScript-Tests bestanden. Schreibtests ausschließlich in isolierten Kant-IT-Kopien. + +**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Inhalts-/Design-Auswahl, Undo, Speichern, Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). + +**Veröffentlichung:** Release-Paket 1.7.4 wird aus dem geprüften Commit gebaut und auf dem bestehenden Git bereitgestellt. + +**Grenzen / nächster Schritt:** Kein erneuter PHP- oder Lighthouse-Lauf für diese reine Editor-Korrektur. Kundenhostings werden nicht verändert; nach Einspielen des Updates den Editor neu öffnen, damit die versionierten Skripte geladen werden. + ## 1.7.3 – Links in neuen Tabs und Bildvorschauen (14.09.2026) **Umgesetzt:** „In neuem Tab öffnen“ bei Buttons, verlinkten Bildern, Symbolen, Symbolkarten, Aufruf-Bändern, Textlinks und Menüeinträgen. Serverseitig validierte Ausgabe mit `target="_blank" rel="noopener"`; normale Links bleiben im selben Tab. Textlinks lassen sich mit vorhandenem Ziel/Häkchen wieder bearbeiten. „Nichts tun“ am Bild deaktiviert auch eine früher hinterlegte Verlinkung. Inhalts-, Galerie-/Logo- und Hintergrundbilder zeigen Vorschauen mit Lade-/Fehlerzustand und Entfernen-Funktion. Entfernen ist rückgängig zu machen und erhält die Datei in der Mediathek. Bildaustausch erhält Link, Bildunterschrift und Gestaltung. Geerbte Hintergrundbilder sind sichtbar und je Gerät entfernbar; kleinere Geräte übernehmen die Entfernung, sofern dort kein eigener Wert besteht. Verläufe bleiben erhalten. Tastaturfokus bleibt beim Umschalten der Klickaktion und nach dem Entfernen eines Hintergrunds bedienbar. diff --git a/public/assets/editor.js b/public/assets/editor.js index e1a9de5..e969b8b 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -179,6 +179,7 @@ function renderProperties() { if (block && propertyPanel === 'design') { renderDesign(block, panel); return; } let imagePreview; const field = (label, key, value, options = {}) => { + const container = options.container || panel; const id = `property-${key}`; const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label; let input; @@ -190,8 +191,13 @@ function renderProperties() { if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; } if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); } if (options.maxLength) input.maxLength = options.maxLength; - input.addEventListener('input', () => { + let previousChoice = input.value; + const applyValue = () => { const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value; + // Native selects may emit input, change, or both. Apply each selection once and keep + // the control in place while its popup or keyboard navigation is active. + if (options.choices && input.value === previousChoice) return; + previousChoice = input.value; history.change(doc => { const current = selected ? locate(doc.blocks, selected)?.block : doc; if (!current) return; @@ -206,15 +212,18 @@ function renderProperties() { if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19); if (key === 'newTab') upgradeDocument(doc, 20); if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; } - }, `${selected || 'page'}:${key}`); + }, options.choices ? null : `${selected || 'page'}:${key}`); if (key === 'src') imagePreview?.update({ src: input.value }); - if (['decorative', 'click'].includes(key)) { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); } + options.onInput?.(next); + if (key === 'decorative') { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); } renderTree(); controls(); schedulePreview(); - }); + }; + input.addEventListener('input', applyValue); + if (options.choices) input.addEventListener('change', applyValue); input.addEventListener('blur', () => { history.mergeKey = null; }); - if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input); - if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); } - if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true }); + if (options.checkbox) container.append(labelEl); else container.append(labelEl, input); + if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); } + if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container }); }; if (!block) { field('Seitentitel', 'title', history.document.title, { maxLength: 160 }); @@ -340,8 +349,12 @@ function renderProperties() { renderImageWidth(block, panel); imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption }); panel.append(imagePreview.element); - field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' }); - if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' }); + const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options'; + const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; }; + const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine'); + field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions }); + field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' }); + showLinkOptions(clickAction); panel.append(linkOptions); const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; diff --git a/scripts/link-media-check.mjs b/scripts/link-media-check.mjs index d1ceebf..c8f8534 100644 --- a/scripts/link-media-check.mjs +++ b/scripts/link-media-check.mjs @@ -37,6 +37,7 @@ try { assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title; data.document.blocks.unshift({ id: 'qa-link-section', type: 'section', children: [ { id: 'qa-link-image', type: 'image', props: { ...original, href: '/impressum', click: 'link' } }, + { id: 'qa-empty-image', type: 'image', props: { ...original, click: 'keine' } }, { id: 'qa-link-button', type: 'button', props: { text: 'QA Neuer Tab', href: '/impressum' } }, { id: 'qa-link-text', type: 'text', props: { text: '

Textlink prüfen

' } }, { id: 'qa-link-gallery', type: 'gallery', children: [{ id: 'qa-gallery-image', type: 'image', props: original }] }, @@ -53,12 +54,40 @@ try { return block && doc.defaultView.getComputedStyle(block).backgroundImage.includes('url(') === hasImage; }, hasImage); const save = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); }; + await select('qa-empty-image'); + const clickAction = () => page.getByLabel('Beim Klick', { exact: true }); + await page.locator('#property-href').waitFor({ state: 'hidden' }); + const nativeSelect = await clickAction().elementHandle(); + await clickAction().click(); await page.keyboard.press('End'); await page.keyboard.press('Enter'); + await page.getByLabel('Linkziel', { exact: true }).waitFor(); + assert.equal(await nativeSelect.evaluate(el => el.isConnected), true, 'Keep the native dropdown mounted while changing the action'); + await nativeSelect.dispose(); + await page.getByLabel('Linkziel', { exact: true }).fill('/kontakt'); + await clickAction().focus(); await page.keyboard.press('Home'); + await page.locator('#property-href').waitFor({ state: 'hidden' }); + await page.keyboard.press('End'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); + assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt'); + await clickAction().selectOption('keine'); + await clickAction().evaluate(el => { el.value = 'link'; el.dispatchEvent(new Event('change', { bubbles: true })); }); + await page.getByLabel('Linkziel', { exact: true }).waitFor(); + assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt'); + await page.locator('#undo').click(); + assert.equal(await clickAction().inputValue(), 'keine'); await page.locator('#property-href').waitFor({ state: 'hidden' }); + await page.locator('#redo').click(); + assert.equal(await clickAction().inputValue(), 'link'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); + await clickAction().selectOption('vergroessern'); + await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#property-newTab').waitFor({ state: 'hidden' }); + await frame.locator('#qa-empty-image a.image-zoom').waitFor(); + await clickAction().selectOption('link'); await page.getByLabel('Linkziel', { exact: true }).waitFor(); + await page.locator('.image-link-options').screenshot({ path: `${report}/image-link-fields.png` }); + checks.push('fresh image URL appears after native dropdown and keyboard selection; URL retained; change-only event supported'); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' }); assert.ok(await thumbnail().evaluate(img => img.naturalWidth > 0)); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check(); await frame.locator('#qa-link-image a[target="_blank"][rel="noopener"]').waitFor(); await page.getByLabel('Beim Klick', { exact: true }).selectOption('keine'); await frame.locator('#qa-link-image a').waitFor({ state: 'detached' }); + await page.getByLabel('Beim Klick', { exact: true }).focus(); await page.getByLabel('Beim Klick', { exact: true }).selectOption('link'); assert.equal(await page.getByLabel('Beim Klick', { exact: true }).evaluate(el => el === document.activeElement), true); await page.getByLabel('Linkziel', { exact: true }).waitFor(); @@ -118,6 +147,9 @@ try { await save(); await page.reload(); await select('qa-link-image'); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true); await thumbnail().waitFor({ state: 'visible' }); + await select('qa-empty-image'); + assert.equal(await clickAction().inputValue(), 'link'); + assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt'); await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); checks.push('button and rich-text target creation/edit/uncheck, safe attributes, save/reload and publication'); @@ -146,6 +178,7 @@ try { assert.equal(await publicPage.locator(selector).getAttribute('rel'), 'noopener'); } assert.equal(await publicPage.getByRole('link', { name: 'QA Menülink', exact: true }).getAttribute('target'), '_blank'); + assert.equal(await publicPage.locator('#qa-empty-image a').getAttribute('href'), '/kontakt'); const popupPromise = publicPage.waitForEvent('popup'); await publicPage.locator('#qa-link-button').click(); const popup = await popupPromise; await popup.waitForLoadState(); assert.equal(new URL(popup.url()).pathname, '/impressum'); assert.equal(await popup.evaluate(() => window.opener), null); await popup.close();