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
5 changes: 5 additions & 0 deletions Classes/Backend/Controller/PageEditController.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
$view->assignMultiple([
'pageId' => $this->pageRecord->getUid(),
'iframeSrc' => $iframeUrl,
'iframeTitle' => sprintf(
'%s: %s',
$this->getLanguageService()->sL('LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:edit_page'),
(string)$this->pageRecord->get('title'),
),
]);

$this->makeButtons($view, $request);
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This extension provides visual editing features for content elements in TYPO3 CM
- 🧲 Drag-and-drop repositioning of content elements (➕ adding and 🗑️ deleting elements)
- ⚡ Real-time preview of changes without page reloads
- 😊 User-friendly interface for non-technical editors
- ♿ Accessibility-aware editing controls for TYPO3 editors

<https://github.com/user-attachments/assets/a4d2a536-40dd-4df8-a980-0b0362654d24>

Expand Down Expand Up @@ -153,6 +154,14 @@ Visual Editor uses your regular frontend CSS for the editing view. CSS that is c

If your project defines custom rich-text styles, add the relevant rules to your frontend CSS so the page output and the editor share the same styling. Projects with custom `lib.parseFunc_RTE` setups may also need matching frontend rules.

## Accessibility

Visual Editor is designed with WCAG 2.2 AA as a goal, but this is not a full compliance claim for every TYPO3 project.

The editor interface includes accessible labels, keyboard-focusable controls, validation announcements, and semantic roles. It has been tested with axe DevTools and NVDA.

Final accessibility depends on the project templates, CSS, semantic HTML, and editor-authored content. Drag-and-drop workflows are pointer-oriented, so projects should verify alternative workflows for their editor needs. Project-level accessibility should be checked in the integrated TYPO3 site.

## License and Authors: License type, contributors, contact information

This extension is licensed under the [GPL-2.0-or-later](https://spdx.org/licenses/GPL-2.0-or-later.html) license.
Expand Down
18 changes: 18 additions & 0 deletions Resources/Private/Language/de.locallang.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@
<trans-unit id="frontend.resetChanges">
<target>Änderungen zurücksetzen</target>
</trans-unit>
<trans-unit id="frontend.editContentElement">
<target>Inhaltselement bearbeiten</target>
</trans-unit>
<trans-unit id="frontend.showContentElement">
<target>Inhaltselement anzeigen</target>
</trans-unit>
<trans-unit id="frontend.deleteContentElement">
<target>Inhaltselement löschen</target>
</trans-unit>
<trans-unit id="frontend.addContentElementButton">
<target>Inhaltselement hinzufügen</target>
</trans-unit>
<trans-unit id="frontend.actionBar">
<target>Aktionsleiste</target>
</trans-unit>
<trans-unit id="frontend.editImageButton">
<target>Bild bearbeiten</target>
</trans-unit>
</body>
</file>
</xliff>
18 changes: 18 additions & 0 deletions Resources/Private/Language/locallang.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@
<trans-unit id="frontend.resetChanges">
<source>reset changes</source>
</trans-unit>
<trans-unit id="frontend.editContentElement">
<source>Edit content element</source>
</trans-unit>
<trans-unit id="frontend.showContentElement">
<source>Show content element</source>
</trans-unit>
<trans-unit id="frontend.deleteContentElement">
<source>Delete content element</source>
</trans-unit>
<trans-unit id="frontend.addContentElementButton">
<source>Add content element</source>
</trans-unit>
<trans-unit id="frontend.actionBar">
<source>Action Bar</source>
</trans-unit>
<trans-unit id="frontend.editImageButton">
<source>Edit Image</source>
</trans-unit>
</body>
</file>
</xliff>
2 changes: 1 addition & 1 deletion Resources/Private/Templates/PageEdit.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

<f:render partial="DocHeader" arguments="{docHeader: docHeader}" />

<iframe id="visual-editor-iframe" src="{iframeSrc}" style="border: none; height:100%; width: 100%; background: #fff;"></iframe>
<iframe id="visual-editor-iframe" src="{iframeSrc}" title="{iframeTitle}" style="border: none; height:100%; width: 100%; background: #fff;"></iframe>
</div>

</html>
6 changes: 4 additions & 2 deletions Resources/Public/Css/editable.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ img[data-veedit] {
outline-offset: -5px;
pointer-events: initial;

&:hover {
&:hover,
&:focus {
/*filter: brightness(0.6);*/
cursor: pointer;
outline-color: #5432fe;
Expand Down Expand Up @@ -108,6 +109,7 @@ img[data-veedit] {
background: center / contain no-repeat url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="white"><path d="m9.293 3.293-8 8A.997.997 0 0 0 1 12v3h3c.265 0 .52-.105.707-.293l8-8-3.414-3.414zM8.999 5l.5.5-5 5-.5-.5 5-5zM4 14H3v-1H2v-1l1-1 2 2-1 1zM13.707 5.707l1.354-1.354a.5.5 0 0 0 0-.707L12.354.939a.5.5 0 0 0-.707 0l-1.354 1.354 3.414 3.414z"/></g></svg>');
}

*:has(> img[data-veedit]:hover)::after {
*:has(> img[data-veedit]:hover)::after,
*:has(> img[data-veedit]:focus)::after {
opacity: 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export class VeAutoSaveToggle extends LitElement {
this.classList.toggle('btn-primary', this.active);
this.classList.toggle('active', this.active);
this.classList.toggle('btn-default', !this.active);
this.setAttribute('role', 'switch');
this.setAttribute('aria-checked', String(this.active));
this.setAttribute('aria-disabled', String(this.hasAttribute('disabled')));
this.tabIndex = this.hasAttribute('disabled') ? -1 : 0;
this.setAttribute('aria-label', this.label);
}

firstUpdated(changedProperties) {
Expand All @@ -37,6 +42,7 @@ export class VeAutoSaveToggle extends LitElement {
this.label = this.innerText;
this.disposeUpdateEditorStateListener = null;
this.onClick = this.#onClick.bind(this);
this.onKeydown = this.#onKeydown.bind(this);
}

connectedCallback() {
Expand All @@ -47,12 +53,14 @@ export class VeAutoSaveToggle extends LitElement {
}

this.addEventListener('click', this.onClick);
this.addEventListener('keydown', this.onKeydown);
}

disconnectedCallback() {
this.disposeUpdateEditorStateListener?.();
this.disposeUpdateEditorStateListener = null;
this.removeEventListener('click', this.onClick);
this.removeEventListener('keydown', this.onKeydown);

super.disconnectedCallback();
}
Expand All @@ -73,6 +81,10 @@ export class VeAutoSaveToggle extends LitElement {
}

#onClick(e) {
if(this.hasAttribute('disabled')){
return;
}

e.preventDefault();
this.active = !this.active;

Expand All @@ -82,6 +94,13 @@ export class VeAutoSaveToggle extends LitElement {
sendMessage('doSave');
}
}
#onKeydown(e) {
if (this.hasAttribute('disabled') || (e.key !== 'Enter' && e.key !== ' ')) {
return;
}
e.preventDefault();
this.#onClick(e);
}

static styles = css`
:host {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,20 @@ export class VeBackendSaveButton extends LitElement {
this.classList.toggle('btn-default', this.isInteractionDisabled && !this.hasInvalidFields);
this.classList.toggle('btn-warning', !this.isVisuallyDisabled);
this.classList.toggle('btn-danger', this.hasInvalidFields);
this.setAttribute('role', 'button');
this.setAttribute('aria-disabled', String(this.isInteractionDisabled));
this.setAttribute('aria-busy', String(this.saving));
this.setAttribute('tabindex', this.isInteractionDisabled ? '-1' : '0');
this.setAttribute('aria-label', this.getLabel());
}

constructor() {
super();
this.count = 0;
this.invalidCount = 0;
this.saving = false;
this.onClick = this.onClick.bind(this);
this.onClick = this.#onClick.bind(this);
this.onKeydown = this.#onKeydown.bind(this);
this.disposeUpdateEditorStateListener = null;
this.disposeOnSaveListener = null;
this.disposeSaveEndedListener = null;
Expand All @@ -51,6 +57,7 @@ export class VeBackendSaveButton extends LitElement {
}

this.addEventListener('click', this.onClick);
this.addEventListener('keydown', this.onKeydown);
}

disconnectedCallback() {
Expand All @@ -61,11 +68,12 @@ export class VeBackendSaveButton extends LitElement {
this.disposeSaveEndedListener?.();
this.disposeSaveEndedListener = null;
this.removeEventListener('click', this.onClick);
this.removeEventListener('keydown', this.onKeydown);

super.disconnectedCallback();
}

render() {
getLabel() {
let label = lll('save');
if (this.count > 0) {
label = this.count === 1 ? lll('save.change') : lll('save.changes', this.count);
Expand All @@ -76,6 +84,11 @@ export class VeBackendSaveButton extends LitElement {
if (this.saving) {
label = lll('saving');
}
return label;
}

render() {
const label = this.getLabel();
const icon = this.hasInvalidFields ? 'actions-exclamation-circle-alt' : 'actions-save';
return html`
<typo3-backend-icon identifier="${icon}" size="small"></typo3-backend-icon>
Expand All @@ -101,14 +114,22 @@ export class VeBackendSaveButton extends LitElement {
}
}

onClick(e) {
#onClick(e) {
e.preventDefault();
if (this.isInteractionDisabled) {
return;
}
sendMessage('doSave');
}

#onKeydown(e) {
if (this.disabled || (e.key !== 'Enter' && e.key !== ' ')) {
return;
}
e.preventDefault();
sendMessage('doSave');
}

get hasChanges() {
return this.count > 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,21 @@ export class VeShowEmptyToggle extends LitElement {
sendMessage('showEmpty', this.active);
this.onShowEmptyChange = this.#onShowEmptyChange.bind(this);
this.onClick = this.#onClick.bind(this);
this.onKeydown = this.#onKeydown.bind(this);
}

connectedCallback() {
super.connectedCallback();

showEmptyActive.addEventListener('change', this.onShowEmptyChange);
this.addEventListener('click', this.onClick);
this.addEventListener('keydown', this.onKeydown);
}

disconnectedCallback() {
showEmptyActive.removeEventListener('change', this.onShowEmptyChange);
this.removeEventListener('click', this.onClick);
this.removeEventListener('keydown', this.onKeydown);

super.disconnectedCallback();
}
Expand All @@ -45,6 +48,10 @@ export class VeShowEmptyToggle extends LitElement {
this.classList.toggle('btn-primary', this.active);
this.classList.toggle('active', this.active);
this.classList.toggle('btn-default', !this.active);
this.setAttribute('role', 'switch');
this.setAttribute('aria-checked', String(this.active));
this.setAttribute('aria-label', this.label);
this.tabIndex = 0;
}

render() {
Expand All @@ -65,6 +72,14 @@ export class VeShowEmptyToggle extends LitElement {
showEmptyActive.set(this.active);
sendMessage('showEmpty', this.active);
}

#onKeydown(e) {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.preventDefault();
this.#onClick(e);
}
}

customElements.define('ve-show-empty-toggle', VeShowEmptyToggle);
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,21 @@ export class VeSpotlightToggle extends LitElement {
this.active = spotlightActive.get();
this.onSpotlightChange = this.#onSpotlightChange.bind(this);
this.onClick = this.#onClick.bind(this);
this.onKeydown = this.#onKeydown.bind(this);
}

connectedCallback() {
super.connectedCallback();

spotlightActive.addEventListener('change', this.onSpotlightChange);
this.addEventListener('click', this.onClick);
this.addEventListener('keydown', this.onKeydown);
}

disconnectedCallback() {
spotlightActive.removeEventListener('change', this.onSpotlightChange);
this.removeEventListener('click', this.onClick);
this.removeEventListener('keydown', this.onKeydown);

super.disconnectedCallback();
}
Expand All @@ -44,6 +47,10 @@ export class VeSpotlightToggle extends LitElement {
this.classList.toggle('btn-primary', this.active);
this.classList.toggle('active', this.active);
this.classList.toggle('btn-default', !this.active);
this.setAttribute('role', 'switch');
this.setAttribute('aria-checked', String(this.active));
this.setAttribute('aria-label', this.label);
this.tabIndex = 0;
}

render() {
Expand All @@ -63,6 +70,14 @@ export class VeSpotlightToggle extends LitElement {
this.active = !this.active;
spotlightActive.set(this.active);
}

#onKeydown(e) {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
e.preventDefault();
this.#onClick(e);
}
}

customElements.define('ve-spotlight-toggle', VeSpotlightToggle);
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {css, html, LitElement} from 'lit';
import {lll} from "@typo3/core/lit-helper.js";
import {getAriaRole} from '@typo3/visual-editor/Frontend/get-aria-role';

/**
* @extends {HTMLElement}
Expand Down Expand Up @@ -51,6 +52,10 @@ export class VeContentArea extends LitElement {
}

element.setAttribute('was', parentTagName);
const role = getAriaRole(parentTagName);
if (role && !element.hasAttribute('role')) {
element.setAttribute('role', role);
}
const properties = Object.keys(element.constructor.properties).map(prop => prop.toLowerCase());
for (const attributeName of parent.getAttributeNames()) {
if (!properties.includes(attributeName.toLowerCase())) {
Expand All @@ -59,7 +64,7 @@ export class VeContentArea extends LitElement {
}

// move every child into element (keep position before and after the element) and remove parent
const oldFirstChild = element.firstChild
const oldFirstChild = element.firstChild;
let isAfterSelf = false;
for (const child of Array.from(parent.childNodes)) {
if (child === element) {
Expand Down
Loading