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 .codegraph/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ ij_smart_tabs = false
ij_visual_guides = 120,160
ij_wrap_on_typing = false

[*.md]
trim_trailing_whitespace = false

[*.css]
ij_css_align_closing_brace_with_properties = false
ij_css_blank_lines_around_nested_selector = 1
Expand Down
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Repository Guidelines

## Project Structure & Module Organization

This repository is a TYPO3 CMS extension. PHP source lives in `Classes/`, grouped by role: backend controllers, middleware, services, ViewHelpers, events, and compatibility helpers. TYPO3 configuration is in `Configuration/`, including backend routes, icons, JavaScript modules, services, TypoScript, and request middlewares. Templates and language files are in `Resources/Private/`; browser assets, web components, CSS, icons, and JavaScript tests are in `Resources/Public/`. PHPUnit tests live in `Tests/Unit/` and `Tests/Functional/`; functional fixtures are under `Tests/Functional/Fixtures/Extensions/blog_example`.

## Build, Test, and Development Commands

- `composer test:unit`: runs PHPUnit unit tests via `Build/phpunit/UnitTests.xml`.
- `composer test:functional`: runs functional tests through `Build/Scripts/runTests.sh` with sqlite by default.
- `npm run test`: runs Node tests for `Resources/Public/JavaScript/**/*.test.js`.
- `npm run lint`: runs ESLint checks for JavaScript files.
- `npm run lint -- --fix`: runs ESLint with automatic fixes for JavaScript files.
- `./Build/Scripts/runTests.sh -s unit -- --filter EditModeServiceTest`: run one PHPUnit test class.
- `./Build/Scripts/runTests.sh -s functional -d mysql`: run functional tests against a specific DBMS.

The `runTests.sh` script requires Docker or Podman and supports PHP `8.2` through `8.5`.

## Coding Style & Naming Conventions

Use strict PHP types and PSR-4 namespaces rooted at `TYPO3\CMS\VisualEditor\`. PHP classes are `PascalCase`; methods and variables are `camelCase`; test classes end in `Test`. Include type information in code, either with native types or docblock types where native types are not expressive enough. Keep services focused and prefer TYPO3 core APIs over custom infrastructure. Follow the existing four-space PHP indentation and two-space JavaScript indentation. Static analysis is configured through `phpstan.neon` with TYPO3-specific baselines; Rector configuration is in `rector.php`.

## Testing Guidelines

Use PHPUnit 11 for PHP tests. Put isolated logic tests in `Tests/Unit/` and TYPO3 integration or database-dependent coverage in `Tests/Functional/`. Prioritize PHPUnit data providers for related input/output scenarios instead of duplicating similar test methods; use named provider keys matching the test method arguments, such as `arguments`, `routeArguments`, and `expected`. Name test methods after observable behavior, for example `getUsedArgumentsReplacesDuplicateRouteArguments`. JavaScript tests should sit beside the module they cover and use the `.test.js` suffix. Only add JavaScript tests when the behavior can be tested without mocking; avoid mock-heavy tests. For JavaScript changes, run `npm run test` and `npm run lint`; use `npm run lint -- --fix` for automatic ESLint fixes before the final lint check.

## Commit & Pull Request Guidelines

Recent history uses short, imperative subjects, usually with scoped prefixes such as `[BUGFIX]`, `[FEATURE]`, or `[DOCS]`, for example `[BUGFIX] Disable frontend cache in edit mode`. Keep commits focused on one behavior. Pull requests should describe the problem, the implemented behavior, and the tests run; link related issues and include screenshots or short recordings for backend or frontend UI changes.

## Agent-Specific Instructions

Before editing, inspect existing patterns and keep changes absolutely narrow. Do not revert unrelated local changes. After running tests, remove generated artifacts such as temporary autoload or cache output unless they are intentionally tracked.

Use constructor dependency injection for services wherever possible instead of `GeneralUtility::makeInstance()`. Reserve direct instantiation for runtime value/context objects that are not services.

If you are unclear about my intent ask me questions!
391 changes: 330 additions & 61 deletions Classes/Backend/Controller/PageEditController.php

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions Classes/Backend/View/PageEditViewMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace TYPO3\CMS\VisualEditor\Backend\View;

enum PageEditViewMode: int
{
case SingleLanguage = 1;
case MultiLanguage = 2;

public function getLabel(): string
{
return match ($this) {
self::SingleLanguage => 'LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:viewMode.singleLanguage',
self::MultiLanguage => 'LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:viewMode.multiLanguage',
};
}
}
4 changes: 4 additions & 0 deletions Classes/EventListener/PolicyMutatedEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ public function __invoke(PolicyMutatedEvent $event): void
}

// add style-src 'unsafe-inline' to allow a working ckeditor in the frontend.
$mutation = new Mutation(MutationMode::Reduce, Directive::StyleSrc, SourceKeyword::nonceProxy);
$event->getCurrentPolicy()->mutate($mutation);
$mutation = new Mutation(MutationMode::Extend, Directive::StyleSrc, SourceKeyword::self, SourceKeyword::unsafeInline);
$event->getCurrentPolicy()->mutate($mutation);

if ($event->getCurrentPolicy()->get(Directive::StyleSrcAttr)) {
$mutation = new Mutation(MutationMode::Reduce, Directive::StyleSrcAttr, SourceKeyword::nonceProxy);
$event->getCurrentPolicy()->mutate($mutation);
$mutation = new Mutation(MutationMode::Extend, Directive::StyleSrcAttr, SourceKeyword::unsafeInline);
$event->getCurrentPolicy()->mutate($mutation);
}
Expand Down
36 changes: 36 additions & 0 deletions Classes/Service/AllowedOriginService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace TYPO3\CMS\VisualEditor\Service;

use TYPO3\CMS\Core\Site\SiteFinder;

final readonly class AllowedOriginService
{
public function __construct(
private SiteFinder $siteFinder,
) {
}

/**
* returns the origins of all configured sites and languages
*
* @return list<string>
*/
public function getAllowedOrigins(): array
{
$allowed = [];
$sites = $this->siteFinder->getAllSites();
foreach ($sites as $site) {
$origin = $site->getBase()->withQuery('')->withPath('')->withUserInfo('')->withFragment('');
$allowed[(string)$origin] = true;
foreach ($site->getLanguages() as $language) {
$origin = $language->getBase()->withQuery('')->withPath('')->withUserInfo('')->withFragment('');
$allowed[(string)$origin] = true;
}
}

return array_values(array_filter(array_keys($allowed)));
}
}
1 change: 1 addition & 0 deletions Classes/Service/ContentElementWrapperService.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public function wrapContentElementHtml(string $table, array $data, string $conte
$uid = $record->getComputedProperties()->getLocalizedUid() ?: $record->getComputedProperties()->getVersionedUid() ?: $record->getUid();
$tag->addAttribute('id', $table . ':' . $uid);
$tag->addAttribute('uid', (string)$uid);
$tag->addAttribute('scrollPositionId', $table . ':' . $record->getUid());
$tag->addAttribute('pid', (string)$record->getPid());
$tag->addAttribute('colPos', $record->get('colPos'));
$tag->addAttribute('hiddenFieldName', $hiddenFieldName);
Expand Down
28 changes: 3 additions & 25 deletions Classes/Service/EditModeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Frontend\Page\PageInformation;

Expand All @@ -39,7 +38,7 @@ public function __construct(
private LocalizationService $localizationService,
private FormProtectionFactory $formProtectionFactory,
private Typo3Version $typo3Version,
private SiteFinder $siteFinder,
private AllowedOriginService $allowedOriginService,
) {
}

Expand Down Expand Up @@ -120,7 +119,7 @@ public function init(ServerRequestInterface $request): void
'allowNewContent' => $this->languageModeService->getAllowNewContent($pageInformation, $siteLanguage, $request),
'token' => $this->formProtectionFactory->createForType('backend')->generateToken('visual_editor', 'save'),
'routeArguments' => (object)$this->flattenBracketKeys(['params' => $this->getUsedArguments($request)]),
'allowedOrigins' => $this->getAllowedOrigins(),
'allowedOrigins' => $this->allowedOriginService->getAllowedOrigins(),
];
$this->assetCollector->addInlineJavaScript(
'veLangInfo',
Expand Down Expand Up @@ -231,27 +230,6 @@ private function loadLanguageLabelsInline(): void
}
}

/**
* returns the origins of all configured sites and languages
*
* @return list<string>
*/
public function getAllowedOrigins(): array
{
$allowed = [];
$sites = $this->siteFinder->getAllSites();
foreach ($sites as $site) {
$origin = $site->getBase()->withQuery('')->withPath('')->withUserInfo('')->withFragment('');
$allowed[(string)$origin] = true;
foreach ($site->getLanguages() as $language) {
$origin = $language->getBase()->withQuery('')->withPath('')->withUserInfo('')->withFragment('');
$allowed[(string)$origin] = true;
}
}

return array_keys($allowed);
}

public function getBackendEditUrl(ServerRequestInterface $request): UriInterface
{
// backend and Frontend Context: determine current page id
Expand All @@ -273,7 +251,7 @@ public function getBackendEditUrl(ServerRequestInterface $request): UriInterface
$usedArguments = $this->getUsedArguments($request);
return $this->uriBuilder->buildUriFromRoute('web_edit', [
'id' => $pageId,
'languages' => [$siteLanguage->getLanguageId()],
// the selected viewMode and languages are saved in be_user->uc
'params' => $usedArguments,
]);
}
Expand Down
2 changes: 2 additions & 0 deletions Classes/ViewHelpers/Render/TextViewHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ private function renderInput(
$tag->addAttribute('table', $record->getMainType());
$tag->addAttribute('uid', (string)($record->getComputedProperties()->getLocalizedUid() ?: $record->getComputedProperties()->getVersionedUid() ?: $record->getUid()));
$tag->addAttribute('field', $field->getName());
$tag->addAttribute('fieldPositionId', $record->getMainType() . ':' . $record->getUid() . ':' . $field->getName());

$tag->addAttribute('name', $label);

Expand Down Expand Up @@ -274,6 +275,7 @@ private function renderRichText(string $value, RecordInterface $record, TextFiel
$tag->addAttribute('table', $record->getMainType());
$tag->addAttribute('uid', (string)($record->getComputedProperties()->getLocalizedUid() ?: $record->getComputedProperties()->getVersionedUid() ?: $record->getUid()));
$tag->addAttribute('field', $field->getName());
$tag->addAttribute('fieldPositionId', $record->getMainType() . ':' . $record->getUid() . ':' . $field->getName());
$tag->addAttribute('name', $label);

$title = $this->localizationService->tryTranslation(
Expand Down
1 change: 1 addition & 0 deletions Configuration/Backend/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'function' => 1,
'languages' => [0],
'showHidden' => true,
'viewMode' => 1,
],
],

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ This extension provides visual editing features for content elements in TYPO3 CM

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

## Compatibility

| Visual Editor | TYPO3 | PHP | Support / Development |
|---------------|---------|-----------|--------------------------------------|
| 1.x | 13 - 14 | 8.2 - 8.5 | features, bugfixes, security updates |

## Installation

1. 📦 `composer require friendsoftypo3/visual-editor` (or install via 🧩 Extension Manager)
Expand All @@ -35,6 +41,7 @@ This extension provides visual editing features for content elements in TYPO3 CM
- 💾 **Saving changes:** use the save button. Autosave is available only when enabled, and the UI says to switch to a workspace if autosave is disabled.
- 🔦 **Finding editable areas:** use Spotlight to highlight editable text, rich text, images, and content elements.
- 👻 **Showing empty fields:** use "show empty" when editable but currently empty fields are hard to see.
- 🌐 **Multi language edits:** available with TYPO3 v14. Use the "Multi language" view to compare translations side by side, edit visible fields in each preview, and save pending changes together.
- ↔️ **Moving content:** drag content elements by their handle. Hold Ctrl while dropping to copy instead of moving.
- ✍️ **Editing special text characters:** type `&shy;` for a soft hyphen and `&nbsp;` for a non-breaking space. Entity-like text that starts with `&`, such as `&nbsp;`, is shown with `&amp;` while editing so it stays literal text.

Expand Down
18 changes: 18 additions & 0 deletions Resources/Private/Language/de.locallang_mod.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@
<trans-unit id="showHidden">
<target>Ausgeblendete zeigen</target>
</trans-unit>
<trans-unit id="viewMode.singleLanguage">
<target>Einzelsprache</target>
</trans-unit>
<trans-unit id="viewMode.multiLanguage">
<target>Mehrsprachig</target>
</trans-unit>
<trans-unit id="crossOrigin.title">
<target>Backend-Ursprung für diese Sprache wechseln</target>
</trans-unit>
<trans-unit id="crossOrigin.description">
<target>Der Visual Editor kann die Seitenvorschau nur laden und mit ihr kommunizieren, wenn das TYPO3-Backend denselben Ursprung wie die Seiten-Sprache verwendet.</target>
</trans-unit>
<trans-unit id="crossOrigin.securityNote">
<target>Das schützt die Sicherheitsgrenzen des Browsers und stellt sicher, dass Editor-Aktionen wie Elementauswahl, Modale und Speichern mit der richtigen Seite verbunden bleiben.</target>
</trans-unit>
<trans-unit id="crossOrigin.button">
<target>Backend mit korrektem Ursprung öffnen</target>
</trans-unit>
</body>
</file>
</xliff>
18 changes: 18 additions & 0 deletions Resources/Private/Language/locallang_mod.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@
<trans-unit id="showHidden">
<source>Show hidden</source>
</trans-unit>
<trans-unit id="viewMode.singleLanguage">
<source>Single language</source>
</trans-unit>
<trans-unit id="viewMode.multiLanguage">
<source>Multi language</source>
</trans-unit>
<trans-unit id="crossOrigin.title">
<source>Change backend origin for this language</source>
</trans-unit>
<trans-unit id="crossOrigin.description">
<source>The visual editor can only load and communicate with the page preview when the TYPO3 backend uses the same origin as the site language.</source>
</trans-unit>
<trans-unit id="crossOrigin.securityNote">
<source>This protects browser security boundaries and keeps editor actions like selecting elements, opening modals, and saving changes connected to the correct page.</source>
</trans-unit>
<trans-unit id="crossOrigin.button">
<source>Open backend on the correct origin</source>
</trans-unit>
</body>
</file>
</xliff>
45 changes: 44 additions & 1 deletion Resources/Private/Templates/PageEdit.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<html
xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:core="http://typo3.org/ns/TYPO3/CMS/Core/ViewHelpers"
data-namespace-typo3-fluid="true"
>

Expand All @@ -10,7 +11,49 @@

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

<iframe id="visual-editor-iframe" src="{iframeSrc}" title="{iframeTitle}" style="border: none; height:100%; width: 100%; background: #fff;"></iframe>
<div style="display: grid; grid-template-columns: repeat({f:count(subject: languages)}, 1fr); grid-template-rows: auto 1fr; gap: 2px;">

<f:if condition="{f:count(subject: languages)} > 1">
<f:then>
<f:for each="{languages}" as="language" iteration="iterator">
<div class="js-replaceable" style="display: flex; gap: 5px; align-items: center; padding: 5px 2px 2px 32px;">
<core:icon identifier="{language.flagIdentifier}"/>
{language.title}
{language.viewButton -> f:format.raw()}
{language.editButton -> f:format.raw()}
{language.translateButton -> f:format.raw()}
<f:comment><!-- TODO handle this nicely-->{language.translateButton -> f:format.raw()}</f:comment>
</div>
</f:for>
</f:then>
<f:else>
<div></div>
</f:else>
</f:if>
<f:for each="{languages}" as="language" iteration="iterator">
<f:if condition="{language.sameOrigin}">
<f:then>
<iframe class="visual-editor-iframe js-visual-editor-language" src="{language.iframeUrl}" title="{language.iframeTitle}" data-language-id="{language.id}" style="border: none; height:100%; width: 100%; background: #fff;"></iframe>
</f:then>
<f:else>
<div class="js-visual-editor-language" data-language-id="{language.id}" style="align-self: start; margin: 24px; padding: 20px; border: 1px solid var(--typo3-component-border-color, #d7d7d7); border-left: 4px solid var(--typo3-state-info-border-color, #5bc0de); border-radius: 4px; background: var(--typo3-component-bg, #fff); box-shadow: 0 2px 10px rgba(0, 0, 0, .08);">
<h2 style="margin: 0 0 12px; font-size: 18px; line-height: 1.3;">
<f:translate key="LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:crossOrigin.title" />
</h2>
<p style="margin: 0 0 10px; line-height: 1.5;">
<f:translate key="LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:crossOrigin.description" />
</p>
<p style="margin: 0 0 16px; line-height: 1.5; color: var(--typo3-text-color-secondary, #555);">
<f:translate key="LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:crossOrigin.securityNote" />
</p>
<a class="btn btn-primary" href="{language.backendUrl}" target="_top">
<f:translate key="LLL:EXT:visual_editor/Resources/Private/Language/locallang_mod.xlf:crossOrigin.button" />
</a>
</div>
</f:else>
</f:if>
</f:for>
</div>
</div>

</html>
8 changes: 2 additions & 6 deletions Resources/Public/Css/editable.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,8 @@
/**
* Highlight the editable area on hover and focus:
*/
.ck-content:hover, .ck-content:focus {
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.50) inset !important;
backdrop-filter: blur(10px) invert(20%) !important;
outline: 0.25rem solid #5432fe !important;
}

.ck-content:hover, .ck-content:focus,
ve-editable-rich-text[highlighted] .ck-content,
ve-editable-rich-text[empty] .ck-content {
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.50) inset !important;
backdrop-filter: blur(10px) invert(20%) !important;
Expand Down
Loading